第5章自然语言处理 5.1自然语言处理综述 5.1.1概述 自然语言处理(Natural Language Processing,NLP)被誉为人工智能皇冠上的明珠,是计算机科学和人工智能领域的一个重要方向。它主要研究人与计算机之间,使用自然语言进行有效通信的各种理论和方法。简单来说,计算机以用户的自然语言数据为输入,在其内部通过定义的算法进行加工、计算等系列操作后(用以模拟人类对自然语言的理解),再返回用户所期望的结果,如图5.1所示。 ■ 图5.1自然语言处理示意图 自然语言处理是一门融语言学、计算机科学和数学于一体的科学。它不仅限于研究语言学,而且是研究能高效实现自然语言理解和自然语言生成的计算机系统,特别是其中的软件系统,因此它是计算机科学的一部分。 随着计算机和互联网技术的发展,自然语言处理技术在各领域广泛应用,如图5.2所示。在过去的几个世纪,工业革命用机械解放了人类的双手,在当今的人工智能革命中,计算机将代替人工,处理大规模的自然语言信息。我们平时常用的搜索引擎、新闻推荐、智能音箱等产品,都是以自然语言处理技术为核心的互联网和人工智能产品。 ■ 图5.2自然语言处理技术在各领域的应用 此外,自然语言处理技术的研究也在日新月异地变化,每年投向ACL(Annual Meeting of the Association for Computational Linguistics,计算语言学年会,自然语言处理领域的顶级会议)的论文数成倍增长,自然语言处理的应用成果被不断刷新,有趣的任务和算法更是层出不穷。 本章为读者简要介绍自然语言处理的发展历程、主要挑战以及如何使用飞桨快速完成常见的自然语言处理任务。 致命密码: 一场关于语言的较量 事实上,人们并非只在近代才开始研究和处理自然语言,在漫长的历史长河中,是否妥当处理自然语言对战争的胜利或是政权的更迭往往起到关键性作用。 16世纪的英国大陆,英格兰和苏格兰刚刚完成统一,统治者为英格兰女王伊丽莎白一世,苏格兰女王玛丽因被视为威胁而遭到囚禁。玛丽女王和其他苏格兰贵族谋反,这些贵族们通过信件同被囚禁的玛丽女王联络,商量如何营救玛丽女王并推翻伊丽莎白女王的统治。为了能更安全地跟同伙沟通,玛丽使用了一种传统的文字加密形式——凯撒密码,对他们之间的信件进行加密,如图5.3所示。 ■ 图5.3凯撒密码 这种密码通过把原文中的每个字母替换成另一个字符的形式,达到加密手段。然而他们的阴谋活动早在英格兰贵族监控之下,英格兰国务大臣弗朗西斯·沃尔辛厄姆爵士通过统计英文字母的出现频率和玛丽女王密函中的字母频率,找到了破解密码的规律。最终,玛丽和其他贵族在举兵谋反前夕被捕。这是近代西方第一次破译密码,开启了近现代密码学的先河。 5.1.2自然语言处理的发展历程 自然语言处理有着悠久的发展史,可粗略地分为兴起、符号主义、连接主义和深度学习4个阶段,如图5.4所示。 ■ 图5.4自然语言处理的发展历程 1. 兴起时期 大多数人认为,自然语言处理的研究兴起于1950年前后。在第二次世界大战中,破解纳粹德国的恩尼格玛密码成为盟军对抗纳 ■ 图5.5恩尼格玛密码机 粹的重要战场,如图5.5所示。经过第二次世界大战的洗礼,曾经参与过密码破译的香农和图灵等科学家开始思考自然语言处理和计算之间的关系。 1948年香农把马尔可夫过程模型(Markov Progress)应用于建模自然语言,并提出把热力学中“熵”(Entropy)的概念扩展到自然语言建模领域。香农相信,自然语言跟其他物理世界的信号一样,是具有统计学规律的,通过统计分析可以帮助我们更好地理解自然语言。 1950年,艾伦·图灵提出著名的图灵测试,标志着人工智能领域的开端。二战后,受到美苏冷战的影响,美国政府开始重视机器自动翻译的研究工作,以便于随时监视苏联最新的科技进展。1954年美国乔治城大学在一项实验中,成功将约60句俄文自动翻译成英文,被视为机器翻译可行的开端。自此开始的10年间,政府与企业相继投入大量的资金,用于机器翻译的研究。 1956年,乔姆斯基(Chomsky)提出了“生成式文法”这一大胆猜想,他假设在客观世界存在一套完备的自然语言生成规律,每一句话都遵守这套规律而生成。总结出这个客观规律,人们就掌握了自然语言的奥秘。从此,自然语言的研究就被分为以语言学为基础的符号主义学派以及以概率统计为基础的连接主义学派。 2. 符号主义时期 在自然语言处理发展的初期阶段,大量的自然语言研究工作都聚焦从语言学角度,分析自然语言的词法、句法等结构信息,并通过总结这些结构之间的规则,达到处理和使用自然语言的目的。这一时期的代表人物就是乔姆斯基和他提出的“生成式文法”。1966年,完全基于规则的对话机器人ELIZA在MIT人工智能实验室诞生了,如图5.6所示。 ■ 图5.6基于规则的聊天机器人ELIZA 然而同年,ALPAC(Automatic Language Processing Advisory Committee,自动语言处理顾问委员会) 发布的一项报告中指出,10年来的机器翻译研究进度缓慢,未达到预期。该项报告发布后,机器翻译和自然语言的研究资金大为减缩,自然语言处理和人工智能的研究进入寒冰期。 3. 连接主义时期 1980年,由于计算机技术的发展和算力的提升,个人计算机可以处理更加复杂的计算任务,自然语言处理研究得以复苏,研究人员开始使用统计机器学习方法处理自然语言任务。 起初研究人员尝试使用浅层神经网络,结合少量标注数据的方式训练模型,虽然取得了一定的效果,但是仍然无法让大部分人满意。后来研究者开始使用人工提取自然语言特征的方式,结合简单的统计机器学习算法解决自然语言问题。其实现方式是基于研究者在不同领域总结的经验,将自然语言抽象成一组特征,使用这组特征结合少量标注样本,训练各种统计机器学习模型(如支持向量机、决策树、随机森林、概率图模型等),完成不同的自然语言任务。 统计机器学习简单、鲁棒性强的特点,这个时期神经网络技术被大部分人所遗忘。 4. 深度学习时期 从2006年深度神经网络反向传播算法的提出开始,伴随着互联网的爆炸式发展和计算机(特别是GPU)算力的进一步提高,人们不再依赖语言学知识和有限的标注数据,自然语言处理领域迈入了深度学习时代。 基于互联网海量数据,并结合深度神经网络的强大拟合能力,人们可以非常轻松地应对各种自然语言处理问题。有越来越多的自然语言处理技术趋于成熟并显现出巨大的商业价值,自然语言处理和人工智能领域的发展进入了鼎盛时期。 自然语言处理的发展经历了多个历史阶段的演进,不同学派之间相互补充促进,共同推动了自然语言处理技术的快速发展。 5.1.3自然语言处理技术面临的挑战 如何让机器像人一样,能够准确地理解和使用自然语言?这是自然语言处理领域面临的最大挑战。为了解决这一问题,需要从语言学和计算两个角度思考。 1. 语言学角度 自然语言数量多、形态各异,理解自然语言对人来说本身也是一件复杂的事情,如同义词、情感倾向、歧义性、长文本处理、语言惯性表达等。通过如下几个例子一同感受一下。 1) 同义词问题 请问下列词语是否为同义词?(题目来源网络: 四川话和东北话6级模拟考试) 瓜兮兮和铁憨憨 嘎嘎和肉(you) 磕搀和难看 吭呲瘪肚和速度慢 2) 情感倾向问题 请问如何正确理解下面两个场景? 场景一: 女朋友生气了,男朋友电话道歉。 女生: 就算你买包我也不会原谅你! 男生: 宝贝,放心,我不买,你别生气了。 问: 女生会不会生气。 场景二: 两个人同宿舍的室友,甲和乙对话。 甲: 钥匙好像没了,你把锁别别。 乙: 到底没没没。 甲: 我也不道没没没。 乙: 要没没你让我别,别别了,别秃鲁了咋整。 问: 到底别不别? 3) 歧义性问题 请问如何理解下面三句话? 一行行行行行,一行不行行行不行。 来到杨过曾经生活过的地方,小龙女说: “我也想过过过儿过过的生活”。 来到儿子等校车的地方,邓超对孙俪说: “我也想等等等等等过的那辆车”。 相信大多数人都需要花点脑筋去理解上面的句子,在不同的上下文中,相同的单词可以具有不同的含义,这种问题称之为歧义性问题。 4) 对话/篇章等长文本处理问题 在处理长文本(如一篇新闻报道、一段多人对话甚至一篇长篇小说)时,需要经常处理各种省略、指代、话题转折和切换等语言学现象,如图5.7所示,都给机器理解自然语言带来了挑战。 ■ 图5.7多轮对话中的指代和省略 5) 探索自然语言理解的本质问题 研表究明,汉字的顺序并不定一能影阅响读,比如当你看完这句话后,才发这现里的字全是都乱的。 上面这句话从语法角度来说完全是错的,但是对大部分人来说完全不影响理解,甚至很多人都不会意识到这句话的语法是错的。 2. 计算角度 自然语言技术的发展除了受语言学的制约外,在计算角度也天然存在局限。顾名思义,计算机是计算的机器,现有的计算机都以浮点数为输入和输出,擅长执行加、减、乘、除类计算。自然语言本身并不是浮点数,计算机为了能存储和显示自然语言,需要把自然语言中的字符转换为一个固定长度(或者变长)的二进制编码,如图5.8所示。 ■ 图5.8计算机计算自然语言流程 由于这个编码本身不是数字,对这个编码的计算往往不具备数学和物理含义。例如,把“法国”和“首都”放在一起,大多数人首先联想到的内容是“巴黎”。但是如果使用“法国”和“首都”的UTF8编码去做加、减、乘、除等运算,是无法轻易获取到“巴黎”的UTF8编码,甚至无法获得一个有效的UTF8编码。因此,如何让计算机有效地计算自然语言,是计算机科学家和工程师面临的巨大挑战。 此外,目前也有研究人员正在关注自然语言处理方法中的社会问题,包括自然语言处理模型中的偏见和歧视、大规模计算对环境和气候带来的影响、传统工作被取代后人的失业和再就业问题等。 5.1.4自然语言处理的常见任务 自然语言处理是非常复杂的领域,是人工智能中最为困难的问题之一,常见的任务如图5.9所示。 ■ 图5.9自然语言处理常见任务 (1) 词和短语级任务: 如切词、词性标注、命名实体识别(如“苹果很好吃”和“苹果很伟大”中的“苹果”哪个是苹果公司?)、同义词计算(如“好吃”的同义词是什么?)等以词为研究对象的任务。 (2) 句子和段落级任务: 如文本倾向性分析(如客户说: “你们公司的产品真好用!”是在夸赞还是在讽刺?)、文本相似度计算(如“我坐高铁去广州”和“我坐火车去广州”是一个意思吗?)等以句子为研究对象的任务。 (3) 对话和篇章级任务: 如机器阅读理解(如使用医药说明书回答患者的咨询问题)、对话系统(如打造一个24小时在线的AI话务员)等复杂的自然语言处理系统等。 (4) 自然语言生成: 如机器翻译(如“我爱飞桨”的英文是什么?)、机器写作(以AI为题目写一首诗)等自然语言生成任务。 5.1.5使用深度学习解决自然语言处理任务的套路 一般来说,使用深度学习框架(如飞桨)解决自然语言处理任务,都可以遵守一个相似的套路,如图5.10所示。 ■ 图5.10使用飞桨框架构建神经网络过程 接下来探索几个经典的自然语言处理任务。 ① 计算词语之间的关系(如同义词): 词嵌入。 ② 理解一个自然语言句子: 文本分类和相似度计算。 5.2词嵌入 5.2.1概述 在自然语言处理任务中,词嵌入(Word Embedding) 是表示自然语言里单词的一种方法,即把每个词都表示为一个N维空间内的点,即一个高维空间内的向量。通过这种方法,实现把自然语言计算转换为向量计算。 在图5.11所示的词嵌入计算任务中,先把每个词(如queen、king等)转换成一个高维空间的向量,这些向量在一定意义上可以代表这个词的语义信息。再通过计算这些向量之间的距离,就可以计算出词语之间的关联关系,从而达到让计算机像计算数值一样去计算自然语言的目的。 ■ 图5.11词嵌入计算示意图 大部分词嵌入模型都需要回答如下两个问题。 ① 如何把词转换为向量? 自然语言单词是离散信号,如“香蕉”“橘子”“水果”在我们看来就是3个离散的词。如何把每个离散的单词转换为一个向量? ② 如何让向量具有语义信息? 比如,我们知道在很多情况下,“香蕉”和“橘子”更加相似,而“香蕉”和“句子”就没有那么相似,同时,“香蕉”和“食物”“水果”的相似程度可能介于“橘子”和“句子”之间。那么,如何让词嵌入具备这样的语义信息? 5.2.2把词转换为向量 自然语言单词是离散信号,如“我”“ 爱”“人工”“智能”。如何把每个离散的单词转换为一个向量?通常情况下,可以维护一个图5.12所示的查询表。表中每一行都存储了一个特定词语的向量值,每一列的第一个元素都代表着这个词本身,以便进行词和向量的映射(如“我”对应的向量值为[0.3,0.5,0.7,0.9,-0.2,0.03])。给定任何一个或者一组单词,都可以通过查询这个表,实现把单词转换为向量的目的,这个查询和替换过程称为嵌入查找。 ■ 图5.12词嵌入查询表 上述过程也可以使用一个字典数据结构实现。事实上,如果不考虑计算效率,使用字典实现上述功能是个不错的选择。然而在进行神经网络计算的过程中,需要大量的算力,常常要借助特定硬件(如GPU)满足训练速度的需求。GPU上所支持的计算都是以张量(Tensor)为单位展开的,因此在实际场景中,需要把嵌入查找的过程转换为张量计算,如图5.13所示。 ■ 图5.13张量计算示意图 假设对于句子“我,爱,人工,智能”,把嵌入查找的过程转换为张量计算的流程如下。 (1) 查询字典,先把句子中的单词转换成一个ID(通常是一个不小于0的整数),这个单词到ID的映射关系可以根据需求自定义(如图5.13中,我1, 人工2,爱3,…)。 (2) 得到ID后,再把每个ID转换成一个固定长度的向量。假设字典的词表中有5000个词,那么,对于单词“我”就可以用一个5000维的向量来表示。由于“我”的ID是1,因此这个向量的第一个元素是1,其他元素都是0([1,0,0,…,0]); 同样对于单词“人工”,第二个元素是1,其他元素都是0。用这种方式就实现了用一个张量表示一个单词。由于每个单词的向量表示都只有一个元素为1,而其他元素为0,因此称上述过程为热码。 (3) 经过独热编码后,句子“我,爱,人工,智能”就被转换成为了一个形状为4×5000的张量,记为V。在这个张量里共有4行5000列,从上到下每一行分别代表了“我”“爱”“人工”“智能”4个单词的独热编码。最后,把这个张量V和另外一个稠密张量W相乘,其中W张量的形状为5000×128(5000表示词表大小,128表示每个词的向量大小)。经过张量乘法,就得到了一个4×128的张量,从而完成了把单词表示成向量的目的。 5.2.3让向量具有语义信息 得到每个单词的向量表示后需要思考下一个问题: 如何让词嵌入具备语义信息呢? 首先学习自然语言处理领域的一个小技巧。在自然语言处理研究中,科研人员通常有一个共识: 使用一个单词的上下文来了解这个单词的语义,比如: (1) “苹果手机质量不错,就是价格有点贵。” (2) “这个苹果很好吃,非常脆。” (3) “菠萝质量也还行,但是不如苹果支持的App多。” 在上面的句子中,通过上下文可以推断出第一个“苹果”指的是苹果手机,第二个“苹果”指的是水果苹果,而第三个“菠萝”指的应该也是一个手机。事实上,在自然语言处理领域,使用上下文描述一个词语或者元素的语义是一个常见且有效的做法。可以使用同样的方式训练词嵌入,让这些词嵌入具备表示语义信息的能力。 2013年,Mikolov提出的经典word2vec算法就是通过上下文来学习语义信息的。word2vec包含两个经典模型,即CBOW(Continuous BagofWords)和Skipgram,如图5.14所示。 ■ 图5.14CBOW和Skipgram语义学习示意图 (1) CBOW: 通过上下文推理中心词。 (2) Skipgram: 根据中心词推理上下文。 假设有一个句子“Pineapples are spiked and yellow”,两个模型的推理方式如下。 (1) 在CBOW中,先在句子中选定一个中心词,并把其他词作为这个中心词的上下文。如图5.14中CBOW所示,把“Spiked”作为中心词,把“Pineapples,are,and,yellow”作为中心词的上下文。在学习过程中,使用上下文的词嵌入推理中心词,这样中心词的语义就被传递到上下文的词嵌入中,如“Spiked→Pineapple”,从而达到学习语义信息的目的。 (2) 在Skipgram中,同样先选定一个中心词,并把其他词作为这个中心词的上下文。如图5.14中Skipgram所示,把“Spiked”作为中心词,把“Pineapples、are、and、 yellow”作为中心词的上下文。不同的是,在学习过程中,使用中心词的词嵌入去推理上下文,这样上下文定义的语义被传入中心词的表示中,如“Pineapple→Spiked”, 从而达到学习语义信息的目的。 说明: 一般来说,CBOW比Skipgram训练速度快,训练过程更加稳定,原因是CBOW使用上下文平均的方式进行训练,每个训练步会见到更多样本。而在生僻字(出现频率低的字)处理上,Skipgram比CBOW效果更好,原因是Skipgram不会刻意回避生僻字(CBOW结构输入存在生僻字时,生僻字会被其他非生僻字的权重冲淡)。 5.2.4CBOW和Skipgram的算法实现 下面以“Pineapples are spiked and yellow”为例介绍CBOW和Skipgram算法的实现。 如图5.15所示,CBOW是一个具有3层结构的神经网络,分别如下。 ■ 图5.15CBOW的算法实现 (1) 输入层: 一个形状为C×V的独热张量,其中C代表上下文中词的个数,通常是一个偶数。假设为4; V表示词表大小,假设为5000,该张量的每一行都用一个上下文词的独热向量表示,如“Pineapples, are, and, yellow”。 (2) 隐藏层: 一个形状为V×N的参数张量W1,一般称为词嵌入,N表示每个词的词向量长度,假设为128。输入张量和W1进行矩阵乘法,就会得到一个形状为C×N的张量。综合考虑上下文中所有词的信息去推理中心词,将上下文中C个词相加得一个1×N的向量,是整个上下文的一个隐含表示。 (3) 输出层: 创建另一个形状为N×V的参数张量,将隐藏层得到的1×N的向量乘以该N×V的参数张量,得到一个形状为1×V的向量。最终,1×V的向量代表了使用上下文去推理中心词。对每个候选词打分,再经过Softmax函数的归一化,即得到了对中心词的推理概率,即 Softmax(Oi)=exp(Oi)∑jexp(Oj) 如图5.16所示,Skipgram是一个具有3层结构的神经网络,分别如下。 ■ 图5.16Skipgram算法实现 (1) 输入层: 接收一个独热张量 V∈R1×vocab_size 作为网络的输入,里面存储着当前句子中心词的独热表示。 (2) 隐藏层: 将张量V乘以一个词嵌入张量W1∈Rvocab_size×embed_size,并把结果作为隐藏层的输出,得到一个形状为R1×embed_size的张量,里面存储着当前句子中心词的词嵌入。 (3) 输出层: 将隐藏层的结果乘以另一个词嵌入张量W2∈Rembed_size×vocab_size,得到一个形状为R1×vocab_size的张量。这个张量经过Softmax变换后,就得到了使用当前中心词对上下文的预测结果。根据Softmax的结果,就可以训练词嵌入模型了。 在实际操作中,使用一个滑动窗口(一般情况下长度是奇数),从左到右开始扫描当前句子。每个扫描出来的片段被当成一个小句子,每个小句子中间的词被认为是中心词,其余的词被认为是这个中心词的上下文。 1. Skipgram的理想实现 使用神经网络实现Skipgram中,模型接收的输入应该有2个不同的张量。 (1) 代表中心词的张量。假设称为中心词V,一般来说,这个张量是一个形状为[batch_size, vocab_size]的独热张量,表示在小批量数据中,每个中心词的ID,对应位置为1,其余为0。 (2) 代表目标词的张量: 目标词是指需要推理出来的上下文词,假设称之为目标词T,一般来说,这个张量是一个形状为[batch_size, 1]的整型张量,这个张量中的每个元素是一个[0, vocab_size-1]的值,代表目标词的ID。 在理想情况下,可以使用一个简单的方式实现Skipgram。即把需要推理的每个目标词都当成一个标签,把Skipgram当成一个大规模分类任务进行网络构建,过程如下。 (1) 声明一个形状为[vocab_size, embedding_size]的张量,作为需要学习的词嵌入,记为W0。对于给定的输入V,使用向量乘法,将V乘以W0就得到一个形状为[batch_size, embedding_size]的张量,记为H=V×W0。这个张量H就可以看成经过词嵌入查表后的结果。 (2) 声明另一个需要学习的参数W1,这个参数的形状为[embedding_size, vocab_size]。将上一步得到的H去乘以W1,得到一个新的张量O=H×W1,此时的O是一个形状为[batch_size, vocab_size]的张量,表示当前这个小批量数据中的每个中心词预测出的目标词的概率。 (3) 使用Softmax函数对小批量数据中每个中心词的预测结果做归一化,即可完成网络构建。 2. Skipgram的实际实现 然而在实际情况中,词汇大小通常很大(几十万甚至几百万),导致W0和W1也会非常大。对于W0而言,所参与的矩阵运算并不是通过一个矩阵乘法实现,而是通过指定ID,对参数W0进行访存的方式获取。然而对W1而言,仍要处理一个非常大的矩阵运算(计算过程非常缓慢,需要消耗大量的内存/显存)。为了缓解这个问题,通常采取负采样(negative_sampling)的方式来近似模拟多分类任务。此时新定义的W0和W1均为形状为 [vocab_size,embedding_size]的张量。 假设有一个中心词c和一个上下文词正样本tp。在Skipgram的理想实现里,需要最大化使用c推理tp的概率。在使用Softmax学习时,需要最大化tp的推理概率,同时最小化其他词表中词的推理概率。之所以计算缓慢,是因为需要对词表中的所有词都计算一遍。然而还可以使用另一种方法,就是随机从词表中选择几个代表词,通过最小化这几个代表词的概率,去近似最小化整体的预测概率。比如,先指定一个中心词(如“人工”)和一个目标词正样本(如“智能”),再随机在词表中采样几个目标词负样本(如“日本”“喝茶”等)。有了这些内容,Skipgram模型就变成了一个二分类任务。对于目标词正样本,需要最大化它的预测概率; 对于目标词负样本,需要最小化它的预测概率。通过这种方式就可以完成计算加速。上述做法称为负采样。 在实现的过程中,通常会让模型接收3个张量输入。 (1) 代表中心词的张量: 假设称之为中心词V,一般来说,这个张量是一个形状为[batch_size, vocab_size]的独热张量,表示在小批量数据中每个中心词具体的ID。 (2) 代表目标词的张量: 假设称之为目标词T,一般来说,这个张量同样是一个形状为[batch_size, vocab_size]的独热张量,表示在小批量数据中每个目标词具体的ID。 (3) 代表目标词标签的张量: 假设称之为标签L,一般来说,这个张量是一个形状为[batch_size, 1]的张量,每个元素不是0就是1(0: 负样本,1: 正样本)。 模型训练过程如下。 (1) 用V去查询W0,用T去查询W1,分别得到两个形状为[batch_size, embedding_size]的张量,记为H1和H2。 (2) 将这两个张量进行点积运算,最终得到一个形状为[batch_size]的张量O=[Oi=∑jH0[i,j]*H1[i,j]]batch_sizei=1。 (3) 使用Sigmoid函数作用在O上,将上述点乘的结果归一化为一个0~1的概率值,作为预测概率,根据标签信息L训练这个模型即可。 在结束模型训练后,一般使用W0作为最终要使用的词嵌入,用W0的向量表示。通过向量点乘的方式,计算不同词之间的相似度。 5.3使用飞桨实现Skipgram 5.3.1数据处理 使用一个合适的语料用于训练word2vec模型。这里选择text8数据集,这个数据集包含了大量从维基百科收集到的英文语料,可以通过如下代码下载数据集,下载后的文件被保存在当前目录的text8.txt文件内: # encoding=utf8 import io import os import sys import requests from collections import OrderedDict import math import random import numpy as np import paddle from paddle.nn import Embedding import paddle.nn.functional as F # 下载语料用来训练word2vec def download(): # 可以从百度云服务器下载一些开源数据集(dataset.bj.bcebos.com) corpus_url = "https://dataset.bj.bcebos.com/word2vec/text8.txt" # 使用Python的requests包下载数据集到本地 web_request = requests.get(corpus_url) corpus = web_request.content # 把下载后的文件存储在当前目录的text8.txt文件内 with open("./text8.txt", "wb") as f: f.write(corpus) f.close() download() 把下载的语料读取到程序里,并打印前500个字符: # 读取text8数据 def load_text8(): with open("./text8.txt", "r") as f: corpus = f.read().strip("\n") f.close() return corpus corpus = load_text8() # 打印前500个字符,简要看一下这个语料的样子 print(corpus[:500]) 输出结果为: anarchism originated as a term of abuse first used against early working class radicals including the diggers of the english revolution and the sans culottes of the french revolution whilst the term is still used in a pejorative way to describe any act that used violent means to destroy the organization of society it has also been taken up as a positive label by self defined anarchists the word anarchism is derived from the greek without archons ruler chief king anarchism as a political philoso 一般来说,在自然语言处理中,需要先对语料进行切词。对于英文来说,可以比较简单地直接使用空格进行切词,代码实现如下: # 对语料进行预处理(分词) def data_preprocess(corpus): # 由于英文单词出现在句首时经常要大写,所以把所有英文字符都转换为小写, # 以便对语料进行归一化处理(Apple vs apple等) corpus = corpus.strip().lower() corpus = corpus.split(" ") return corpus corpus = data_preprocess(corpus) print(corpus[:50]) 在经过切词后,需要对语料进行统计,为每个词构造ID。一般来说,可以根据每个词在语料中出现的频次构造ID,频次越高ID越小,便于对词典进行管理,代码实现如下: # 构造词典,统计每个词的频率,并根据频率将每个词转换为一个整数id def build_dict(corpus): # 首先统计每个不同词的频率(出现的次数),使用一个词典记录 word_freq_dict = dict() for word in corpus: if word not in word_freq_dict: word_freq_dict[word] = 0 word_freq_dict[word] += 1 # 将这个词典中的词按照出现次数排序,出现次数越高排序越靠前 # 一般来说,出现频率高的高频词往往是1,the,you这种代词,而出现频率低的词,往往是一些名词,如nlp word_freq_dict = sorted(word_freq_dict.items(), key = lambda x:x[1], reverse = True) # 构造3个不同的词典,分别存储, # 每个词到id的映射关系:word2id_dict # 每个id出现的频率:word2id_freq # 每个id到词的映射关系:id2word_dict word2id_dict = dict() word2id_freq = dict() id2word_dict = dict() # 按照频率从高到低,开始遍历每个单词,并为这个单词构造一个独一无二的id for word, freq in word_freq_dict: curr_id = len(word2id_dict) word2id_dict[word] = curr_id word2id_freq[word2id_dict[word]] = freq id2word_dict[curr_id] = word return word2id_freq, word2id_dict, id2word_dict word2id_freq, word2id_dict, id2word_dict = build_dict(corpus) vocab_size = len(word2id_freq) print("there are totoally %d different words in the corpus" % vocab_size) for _, (word, word_id) in zip(range(50), word2id_dict.items()): print("word %s, its id %d, its word freq %d" % (word, word_id, word2id_freq[word_id])) 得到word2id词典后,还需要进一步处理原始语料,把每个词替换成对应的ID,便于神经网络进行处理,代码实现如下: # 把语料转换为id序列 def convert_corpus_to_id(corpus, word2id_dict): # 使用一个循环,将语料中的每个词替换成对应的id,以便于神经网络进行处理 corpus = [word2id_dict[word] for word in corpus] return corpus corpus = convert_corpus_to_id(corpus, word2id_dict) print("%d tokens in the corpus" % len(corpus)) print(corpus[:50]) 接下来,需要使用二次采样法处理原始文本。二次采样法的主要思想是降低高频词在语料中出现的频次,降低的方法是随机高频的词抛弃,频率越高,被抛弃的概率就越高,频率越低,被抛弃的概率就越低,这样像标点符号或冠词这样的高频词就会被抛弃,从而优化整个词表的词嵌入训练效果,代码实现如下: # 使用二次采样算法处理语料,强化训练效果 def subsampling(corpus, word2id_freq): # discard函数决定了一个词会不会被替换,这个函数是具有随机性的,每次调用结果不同 # 如果一个词出现的频率很大,那么它被遗弃的概率就很大 def discard(word_id): return random.uniform(0, 1) < 1 - math.sqrt( 1e-4 / word2id_freq[word_id] * len(corpus)) corpus = [word for word in corpus if not discard(word)] return corpus corpus = subsampling(corpus, word2id_freq) print("%d tokens in the corpus" % len(corpus)) print(corpus[:50]) 完成语料数据预处理后,需要构造训练数据。根据前文的描述,需要使用一个滑动窗口对语料从左到右扫描,在每个窗口内,中心词需要预测它的上下文,并形成训练数据。 在实际操作中,由于词表往往很大,对大词表的一些矩阵运算(如Softmax)需要消耗巨大的资源,因此可以通过负采样的方式模拟Softmax的结果,代码实现如下。 ① 给定一个中心词和一个需要预测的上下文词,把这个上下文词作为正样本。 ② 通过词表随机采样的方式,选择若干个负样本。 ③ 把一个大规模分类问题转化为一个二分类问题,通过这种方式优化计算速度。 # 构造数据,准备模型训练 # max_window_size代表了最大的window_size的大小,程序会根据max_window_size从左到右扫描 # 整个语料 # negative_sample_num代表了对每个正样本需要随机采样多少负样本用于训练, # 一般来说,negative_sample_num的值越大,训练效果越稳定,但是训练速度越慢 def build_data(corpus, word2id_dict, word2id_freq, max_window_size = 3, negative_sample_num = 4): # 使用一个list存储处理好的数据 dataset = [] # 从左到右,开始枚举每个中心点的位置 for center_word_idx in range(len(corpus)): # 以max_window_size为上限,随机采样一个window_size,这样会使训练更加稳定 window_size = random.randint(1, max_window_size) # 当前的中心词就是center_word_idx所指向的词 center_word = corpus[center_word_idx] # 以当前中心词为中心,左、右两侧在window_size内的词都可以看成正样本 positive_word_range = (max(0, center_word_idx - window_size), min(len(corpus) - 1, center_word_idx + window_size)) positive_word_candidates = [corpus[idx] for idx in range(positive_word_range[0], positive_word_range[1]+1) if idx != center_word_idx] # 对于每个正样本来说,随机采样negative_sample_num个负样本用于训练 for positive_word in positive_word_candidates: # 首先把(中心词,正样本,label=1)的三元组数据放入dataset中, # 这里label=1表示这个样本是个正样本 dataset.append((center_word, positive_word, 1)) # 开始负采样 i = 0 while i < negative_sample_num: negative_word_candidate = random.randint(0, vocab_size-1) if negative_word_candidate not in positive_word_candidates: # 把(中心词,正样本,label=0)的三元组数据放入dataset中, # 这里label=0表示这个样本是个负样本 dataset.append((center_word, negative_word_candidate, 0)) i += 1 return dataset corpus_light = corpus[:int(len(corpus)*0.2)] dataset = build_data(corpus_light, word2id_dict, word2id_freq) for _, (center_word, target_word, label) in zip(range(50), dataset): print("center_word %s, target %s, label %d" % (id2word_dict[center_word], id2word_dict[target_word], label)) 训练数据准备好后,把训练数据都组装成小批量数据,并准备输入到网络中进行训练。代码实现如下: # 构造小批量数据,准备对模型进行训练 # 将不同类型的数据放到不同的张量里,便于神经网络进行处理 # 并通过NumPy的array函数构造出不同的张量,并把这些张量送入神经网络中进行训练 def build_batch(dataset, batch_size, epoch_num): # center_word_batch缓存batch_size个中心词 center_word_batch = [] # target_word_batch缓存batch_size个目标词(可以是正样本或者负样本) target_word_batch = [] # label_batch缓存了batch_size个0或1的标签,用于模型训练 label_batch = [] for epoch in range(epoch_num): # 每次开启一个新epoch之前,都对数据进行一次随机打乱以提高训练效果 random.shuffle(dataset) for center_word, target_word, label in dataset: # 遍历dataset中的每个样本,并将这些数据送到不同的张量里 center_word_batch.append([center_word]) target_word_batch.append([target_word]) label_batch.append(label) # 当样本积攒到一个batch_size后,就把数据都返回来 # 在这里使用NumPy的array函数把list封装成张量 # 并使用Python的迭代器机制,将数据产生出来 # 使用迭代器的好处是可以节省内存 if len(center_word_batch) == batch_size: yield np.array(center_word_batch).astype("int64"), \ np.array(target_word_batch).astype("int64"), \ np.array(label_batch).astype("float32") center_word_batch = [] target_word_batch = [] label_batch = [] if len(center_word_batch) > 0: yield np.array(center_word_batch).astype("int64"), \ np.array(target_word_batch).astype("int64"), \ np.array(label_batch).astype("float32") for _, batch in zip(range(10), build_batch(dataset, 128, 3)): print(batch) 5.3.2网络定义 定义Skipgram的网络结构用于模型训练。在飞桨动态图中,对于任意网络,都需要定义一个继承自paddle.nn.layer的类来搭建网络结构、参数等数据的声明。同时需要在forward函数中定义网络的计算逻辑。值得注意的是,仅需要定义网络的前向计算逻辑,飞桨会自动完成神经网络的后向计算。 在Skipgram的网络结构中,使用paddle.nn.Embedding API实现Embedding的网络层。 paddle.nn.Embedding(numembeddings, embeddingdim, paddingidx=None, sparse=False, weightattr=None, name=None) 该接口用于构建嵌入的一个可调用对象,其根据输入中的ID信息从嵌入矩阵中查询对应嵌入信息,并根据输入的size (num_embedding, embedding_dim)自动构造一个二维嵌入矩阵。输出张量的形状是在输入Tensor shape的最后一维后面添加了emb_size的维度。注: 输入中的ID必须满足 0≤ID