前前后后写论文也有将近一年的时间了。这个研究的课题到目前还比较热门,在此分享博士论文。希望读者有所收获,少走一些弯路。

论文下载地址:http://pan.baidu.com/s/1jGWmmZO

感谢赵老师的指导,以及各位老师同学的宝贵建议!

有什么疑问或者发现什么问题都可以直接在这里评论。

  自认为这是一篇有用的文章,因此在发表之前先放到 arXiv 上,供大家参考,请批评指正。
  论文地址:http://arxiv.org/abs/1507.05523
  实验代码地址:https://github.com/licstar/compare

  准备这篇论文大概花了半年时间,从去年 11 月开始做实验,到今年成文。期间消耗大约 10 万 CPU 小时,然后在几十万个结果里面做人肉数据挖掘,最后得到了一些可能有用的结论。

  看标题就能够猜到论文的大概内容了,就是希望找到一种简单有效的词向量学习方法。怎么会想到做这件苦逼的事情呢?半年之前在我准备上一篇论文的时候(RCNN,强行广告,欢迎引用,文章点此,上面的常用链接里有文章相关信息)有一个意外的发现:选用不同的词向量作为模型的初始值,效果的差异非常大!!!当时正值我已经调了好几天参数,黔驴技穷之时,没想到手滑用错了个词向量,效果突然变 NB 了。这下我就脑洞大开了,要是能搞出个宇宙第一词向量,岂不是以后可以随便灌水?后面的故事肯定就能猜到了,半年之前我掉进这个坑里,只为寻找这个“宇宙第一”词向量。然后我实现了几个主流的词向量模型,遍历了各种语料和参数,榨干了实验室机器的空闲资源跑词向量,从生成的几十万个词向量里寻找规律,最后发现“宇宙第一”词向量应该是不存在的,不过生成一个好用的词向量还是有一定套路可循的。为避免某些读者对后面冗长的文字没有兴趣,我先用最简单的话描述我们发现的这个套路,那就是:

首先根据具体任务,选一个领域相似的语料,在这个条件下,语料越大越好。然后下载一个 word2vec 的新版(14年9月更新),语料小(小于一亿词,约 500MB 的文本文件)的时候用 Skip-gram 模型,语料大的时候用 CBOW 模型。最后记得设置迭代次数为三五十次,维度至少选 50,就可以了。

  似乎结论很“显然”,不过这种简单的策略,真的就这么有效吗?word2vec 在半年前已经是最流行的词向量生成工具了,它之所以能这么流行,除了头上顶着 Google 光环,很大程度上是因为这是一个好用的工具包,而不一定是因为它的效果就是最好的。质疑 word2vec 的效果,是因为它的两个模型(Skip-gram 和 CBOW)相对前人的模型做了大量简化,去掉了词序,又去掉了神经网络的隐藏层,最后变成了一个 log 线性模型。我主观地认为,这些简化是会影响词向量的性能的,毕竟词序很有用(上面提到的我的上一篇论文就是讲这个的,再次广告);然后神经网络的表达能力也比 logistic 回归要强得多。这两部分简化到底会带来多少损失,还是要靠数据来说话。所以我考虑的第一个问题就是,什么模型效果好。前面博客提到过的经典模型(NNLM、LBL、C&W)在 word2vec 出现之前,是最主流的方法。这些方法就保留了词序信息,而且也有隐藏层,都放到一起比较。有了这些模型,还缺一个只保留词序,但没有隐藏层的模型。所以我设计了一个中间模型,叫 Order,保留了词序,但没有隐藏层。最后再加上开源的 GloVe,一共比较了 Skip-gram、CBOW、Order、LBL、NNLM、C&W、GloVe 这 7 个模型。

  那么最严峻的问题来了,怎么样才算好的词向量呢,应该用什么指标来衡量呢?Mikolov 推广 word2vec 的时候,设计了一个经典的指标(king-queen=man-woman),由于有开放的数据集和评测代码,后来大量的工作都只用这一个指标来评价词向量。我觉得只用这个指标肯定是不对的,为什么词向量非得有这种线性平移的特性啊,如果说这是个加分项,还说得过去,但要成为唯一衡量标准,似乎没什么道理。我觉得 GloVe 那篇论文就做的很好,里面比较了一大堆指标。所以我就从根源想起,我们拿词向量是用来干什么呢?①有人拿它寻找近义词或者相关词,直接根据向量空间里的距离远近来判定词的关系。②也有不少早期的工作,直接拿词向量做特征,在现有系统中加入词向量作为特征。特征嘛,就是要个多样性,虽然不知道词向量包含了什么信息,但是说不定就带着新的信息,效果就能提升了。③还有大量基于神经网络的工作,拿词向量作为神经网络的初始值。神经网络的初始值选得好,就有可能收敛到更好的局部最优解。好,就是这三种指标了:语义特性、用作特征、用作初始值。基于这三大类用法,我们具体找了 8 个指标,进行比较,综合评价词向量的性能。(题外话,其实我从一开始就很想找到一个终极指标,用一个指标定江山,但是自从看到这 8 个指标的结果如此不一致之后,我终于放弃了这个念头。)

  为了公平的比较,肯定需要设定一个相同的语料,所有模型都用同样的语料来训练。选个什么语料呢?脑海中的第一反应是:大语料。其实小规模语料(几十兆) Turian 在 2010 年已经做过一些实验了,比较了 C&W 和 HLBL 这两个模型。大规模语料下,结论会不会不一样呢?找了两个大语料,维基百科英文版和纽约时报。此前,主流论点是:语料越大越好。所有语料都堆到一起,不管是什么内容,我语料越大,涵盖的语义信息就越丰富,效果就越好。GloVe 和 C&W 都是这么干的。自然,我也把这两个大语料混合在一起训练了。同时由于好奇心,我还把这两个语料单独拿出来训练词向量。反正就是挂机跑嘛。另一方面,为了验证一下是不是真的语料越大越好,我也在大语料中抽了 10M、100M、1G 词的子集,顺便验证一下别人的结论。说到这里我忍不住剧透了,实验中最意外的结果就是,语料对词向量的影响比模型的影响要重要得多得多得多(重要的事说三遍)。本来我只想固定一个语料,公平比较模型,结果却发现语料比模型重要(抱歉,是四遍)……后来又加了一个小规模的 IMDB 语料,进一步证实了这个结论。实验结果就是这样。

  当然,为了公平比较,还有一些其它的因素需要考虑,比如上下文窗口都开成一样大,词向量的维度也需要统一。还有个很重要的参数,那就是迭代次数。这些词向量模型都是用迭代方法优化的,迭代次数肯定会影响性能。对所有模型固定迭代次数可能不合适,毕竟模型之间的差异还是蛮大的。所以为了省事,我们对所有实验迭代足够多的次数,从 100 次到 10000 次,取最好的那次。做了这个决定之后,人省事了,机器累死了,而且对于每个语料、每个模型、每个参数,还要保留不同迭代次数的词向量,很快就把 3T 硬盘塞满了……一边删数据,一边继续做实验……这种暴力手段肯定是不适合实际应用的,所以我们也努力从实验数据中寻找规律,希望找到一种合适的迭代停止条件。

  最后总结一下,我们认为模型、语料、参数三方面会影响词向量的训练,所以从这三方面入手分析到底应该怎么生成一个好的词向量,论文由此展开。在博客里主要就介绍一下不方便写进论文里的故事和背景。

—————-枯燥开始的分割线—————-

  简单列举一下文章的贡献。
  模型方面,因为所有的词向量模型都是基于分布式分布假说的(distributional hypothesis):拥有相似上下文的词,词义相似。这里有两个对象,一个是我们需要关注的词(目标词),另一个是这个词对应的上下文。所以,我们从两个角度去总结模型:1.目标词和上下文的关系,2.上下文怎么表示。在这种分类体系下,我们对现有的主流词向量模型进行了总结和比较,发现,目标词和上下文的关系主要有两种,大多数模型都是根据上下文,预测目标词。而 C&W 模型则是对目标词和上下文这一组合,打分。实验发现,通过上下文预测目标词的模型,得到的词向量,更能捕获替换关系(paradigmatic)。在上下文的表示方面,我们分析了几种表示方法之后,发现可以通过模型复杂程度对这些模型进行排序。排序之后,实验结果就容易解释了:简单的模型(Skip-gram)在小语料下表现好,复杂的模型在大语料下略有优势。从实践中看,word2vec 的 CBOW 模型在 GB 级别的语料下已经足够好。我前面提到的 Order 模型,加入了词序信息,其实很多时候比 CBOW 更好,不过带来的提升并不大。

  语料方面,很多论文都提到语料越大越好,我们发现,语料的领域更重要。领域选好了,可能只要 1/10 甚至 1/100 的语料,就能达到一个大规模泛领域语料的效果。有时候语料选的不对,甚至会导致负面效果(比随机词向量效果还差)。文章还做了实验,当只有小规模的领域内语料,而有大规模的领域外语料时,到底是语料越纯越好,还是越大越好。在我们的实验中,是越纯越好。这一部分实验数据比较丰富,原文相对清楚一些。

  参数方面,主要考虑了迭代次数和词向量的维度。其实词向量都是迭代算法,一般迭代算法都需要多迭代几次。旧版的 word2vec 只迭代了一次,效果很受限制,换新版就好了(也欢迎用我们论文实验的方法,用 AdaGrad 优化,相比原版 word2vec 的学习速率下降法,这样还能在之前的基础上继续迭代)。然后迭代次数怎么选呢?机器学习里很常用的迭代停止指标是看验证集的损失是否到达峰值,认为这个时候模型开始过拟合了。按照这个方法,我们可以从训练语料中分出一个验证集看损失函数的变化。但是实验中我们发现,这种策略并不好。主要原因就是,训练词向量的目标是,尽可能精确地预测目标词,这一目标和实际任务并不一致。所以更好的方法是,直接拿实际任务的验证集来做终止条件。如果实际任务做起来很慢(比如 NER 任务的开源实现大概做一次要两小时),文章还给了一种参考的方法,随便挑一个任务当验证集用,一般都比损失函数靠谱。
  词向量的维度则只有一些实验结果。做词向量语义分析任务的时候,一般维度越大效果越好。做具体 NLP 任务(用作特征、用作神经网络初始化)的时候,50 维之后效果提升就比较少了。这部分的结果很依赖于具体任务的实现,或许用上了更先进的神经网络优化方法,词向量作为初始值带来的影响又会有新的结论。

—————-枯燥结束的分割线—————-

  完全公平以及全面的比较是很难做到的,我们也在尽可能尝试逼近这个目标,希望这些结论会有用。请批评指正。当然,也非常非常欢迎引用~~~~~

  这篇博客是我看了半年的论文后,自己对 Deep Learning 在 NLP 领域中应用的理解和总结,在此分享。其中必然有局限性,欢迎各种交流,随便拍。

  Deep Learning 算法已经在图像和音频领域取得了惊人的成果,但是在 NLP 领域中尚未见到如此激动人心的结果。关于这个原因,引一条我比较赞同的微博。

@王威廉:Steve Renals算了一下icassp录取文章题目中包含deep learning的数量,发现有44篇,而naacl则有0篇。有一种说法是,语言(词、句子、篇章等)属于人类认知过程中产生的高层认知抽象实体,而语音和图像属于较为底层的原始输入信号,所以后两者更适合做deep learning来学习特征。
2013年3月4日 14:46

  第一句就先不用管了,毕竟今年的 ACL 已经被灌了好多 Deep Learning 的论文了。第二句我很认同,不过我也有信心以后一定有人能挖掘出语言这种高层次抽象中的本质。不论最后这种方法是不是 Deep Learning,就目前而言,Deep Learning 在 NLP 领域中的研究已经将高深莫测的人类语言撕开了一层神秘的面纱。
  我觉得其中最有趣也是最基本的,就是“词向量”了。

  将词用“词向量”的方式表示可谓是将 Deep Learning 算法引入 NLP 领域的一个核心技术。大多数宣称用了 Deep Learning 的论文,其中往往也用了词向量。

继续阅读

  很久很久以前,当我想用 C 语言处理中文时,遇到了一些麻烦:C 语言中的 char 只占用 1 字节,但是 GBK 编码的汉字会占用两个字节。如果直接使用 char,会遇到一些非常神奇的问题,比如“页苑估”字符串中含有“吃饭”子串[1]。处理 GBK 编码其实也挺简单,判断一下,如果发现某个 char 的最高位是1,就和下一个 char 合起来考虑。偷懒的办法就是预处理一遍,把合并后的结果存到 short int(其实还有 wchar_t 这种专用的宽字符变量类型,使用时需要注意在 Windows 下是2字节,而在 Linux 下是4字节) 里,这样每个变量就可以表示一个字符了。

  当我知道 C#、Java 的 char 是一个 16 位的存储单元时,我就开始天真的以为“终于一个 char 可以表示一个汉字了”。虽然之后也听说了 C# 和 Java 中的 char 是以 Unicode 的形式存储的(确切的说,是 UTF-16),但是对一个 char 表示一个字符的观念,一直没有改变,直到昨天膝盖中了一箭……

  我在处理维基百科语料时,想统计一下里面出现了多少种不同的字符。随手就写了一个逐 char 的循环,统计之后输出 <char, 出现次数> 的二元组。 结果写文件的时候,抛出了这个异常:

未经处理的异常: System.Text.EncoderFallbackException: 无法将位于索引 551 处的 Unicode 字符 \uD86A 转换为指定的代码页。

  搜索良久,没发现有人遇到这个问题。只好开始猜想,是不是有些字符能用 UTF-16 表示,但不能用 UTF-8 表示?又或许有些 char 不能独立存在?

  第二个猜想在 Unicode 的官网找到了答案[2],同时也否定了第一个猜想:

The Unicode Standard encodes characters in the range U+0000..U+10FFFF, which amounts to a 21-bit code space. Depending on the encoding form you choose (UTF-8, UTF-16, or UTF-32), each character will then be represented either as a sequence of one to four 8-bit bytes, one or two 16-bit code units, or a single 32-bit code unit.

  里面涉及了两个要点。1. Unicode 编码的范围是固定的,Unicode 字符可以选用 UTF-8、UTF-16、UTF-32 这些编码方式来存储,也就是说这三种编码方式可以无损转换。2. 一个 Unicode 字符,在使用 UTF-16 编码方式存储时,会使用1个或两个码元(code unit),也就是 C# 里面的 char。

  于是有些Unicode字符,在 UTF-16 编码方式下,需要用两个码元来表示。在维基百科里找到一个例子:“𪚥”。这个字就需要两个码元来表示。UTF-16 把表示这种字的两个码元称作“代理对(surrogate pair)”,代理对由高位代理和低位代理组合而成,下面的 C# 代码展示了这个分解过程。

var str = "𪚥";
Console.WriteLine(str.Length);
Console.WriteLine(Char.IsHighSurrogate(str[0]));
Console.WriteLine(Char.IsLowSurrogate(str[1]));

  那是不是一个 Unicode 字符就是一个显示字符呢?答案居然是“否”!

  世界上还有“组合字符[4]”这种神一般的存在,最常见的一个肯定很多人都见过“ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้”。用来破坏排版的神器。这真的是一个显示字符,不信的话,你可以把它复制下来粘贴到 Word 里,看看是不是占了一个字符的位置。当然,一些比较弱的软件可能不能正常显示这个字符。组合字符一般是用来给拉丁文加重音符号等附加符号的,比如 Ä = A + ¨,也可以在一个字符后面加入一堆附加符号,形成刚才那个神器的效果。

  当然,C#这样的高级语言处理显示字符也是比较方便的,使用 StringInfo [5]类即可,比如下面的代码会输出 39、1。想要逐字符提取可以用 StringInfo 类中的其它方法,非常方便。Java 中应该也有类似的方法,没去调研。

var str = "ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้";
Console.WriteLine(str.Length);
StringInfo si = new StringInfo(str);
Console.WriteLine(si.LengthInTextElements);

  最后,在@trouger 的帮助下,我们发现 UTF-8 编码[6]在多字节时,各字节的首位均是 1。利用这一点,如果我想用 C 语言写一个简单的程序,把 UTF-8 编码的文件按照空格分割(或者任意ASCII字符),直接使用 C 语言的 char 就可以完全胜任,无需考虑复杂的编码方式。

[1] 百度之星2007程序设计大赛 两场初赛题目点评 http://www.baiduer.com.cn/2007-06/1207.html
[2] UTF-8, UTF-16, UTF-32 & BOM http://www.unicode.org/faq/utf_bom.html
[3] 码元 – 维基百科 http://zh.wikipedia.org/wiki/码元
[4] 组合字符 – 维基百科 http://zh.wikipedia.org/wiki/组合字符
[5] StringInfo Class http://msdn.microsoft.com/en-us/library/system.globalization.stringinfo.aspx
[6] UTF-8 – 维基百科 http://zh.wikipedia.org/wiki/UTF-8

  最近做实验需要较大规模的中文语料,很自然的就想到使用维基百科的中文数据。

  使用维基百科做训练语料有很多好处:

  1. 维基百科资源获取非常方便,有 Wiki Dump 可以直接下载,所有的最新备份都在里面。最近的一次备份是3月底,也就是5天前。相比之下,其他很多语料都需要用爬虫抓取,或者付费获得。
  2. 维基百科的文档解析有非常多的成熟工具,直接使用开源工具即可完成正文的提取。
  3. 维基百科的质量较高,而且领域广泛(比较适合我要做的问题)。

  当然,缺点也有:最主要的就是数量较少,相比国内的百度百科、互动百科等,数据量要少一个数量级。

  直接切入正题。
  

第一步,下载中文的 Wiki Dump

  链接是:http://download.wikipedia.com/zhwiki/latest/zhwiki-latest-pages-articles.xml.bz2。这个压缩包里面存的是标题、正文部分,如果需要其他数据,如页面跳转、历史编辑记录等,可以到目录下找别的下载链接。
  

第二步,使用 Wikipedia Extractor 抽取正文文本

  Wikipedia Extractor 是意大利人用 Python 写的一个维基百科抽取器,使用非常方便。下载之后直接使用这条命令即可完成抽取,运行了大约半小时的时间。
  bzcat zhwiki-latest-pages-articles.xml.bz2 | python WikiExtractor.py -b1000M -o extracted >output.txt
  参数 -b1000M 表示以 1000M 为单位切分文件,默认是 500K。由于最后生成的正文文本不到 600M,把参数设置的大一些可以保证最后的抽取结果全部存在一个文件里。
  

第三步,繁简转换

  维基百科的中文数据是繁简混杂的,里面包含大陆简体、台湾繁体、港澳繁体等多种不同的数据。有时候在一篇文章的不同段落间也会使用不同的繁简字。
  解决这个问题最佳的办法应该是直接使用维基百科自身的繁简转换方法(参照 http://zh.wikipedia.org/wiki/Wikipedia:繁简处理)。不过维基百科网站虽然是开源的,但要把里面的繁简转换功能拆解出来,有一定的难度。
  为了方便起见,我直接使用了开源项目 opencc。参照安装说明的方法,安装完成之后,使用下面的命令进行繁简转换,整个过程大约需要1分钟。(opencc新地址 2016-10-11更新)
  opencc -i wiki_00 -o wiki_chs -c zht2zhs.ini
  命令中的 wiki_00 这个文件是此前使用 Wikipedia Extractor 得到的。

  到此为止,已经完成了大部分繁简转换工作。实际上,维基百科使用的繁简转换方法是以词表为准,外加人工修正。人工修正之后的文字是这种格式,多数是为了解决各地术语名称不同的问题:

他的主要成就包括Emacs及後來的GNU Emacs,GNU C 編譯器及-{zh-hant:GNU 除錯器;zh-hans:GDB 调试器}-。

  对付这种可以简单的使用正则表达式来解决。一般简体中文的限定词是 zh-hans 或 zh-cn,在C#中用以下代码即可完成替换:

s = Regex.Replace(s, @"-\{.*?(zh-hans|zh-cn):([^;]*?);.*?\}-", @"$2");

  由于 Wikipedia Extractor 抽取正文时,会将有特殊标记的外文直接剔除,最后形成类似这样的正文:

西方语言中“数学”(;)一词源自于古希腊语的()

  虽然上面这句话是读不通的,但鉴于这种句子对我要处理的问题影响不大,就暂且忽略了。最后再将「」「」『』这些符号替换成引号,顺便删除空括号,就大功告成了!

  通过上述方法得到的维基百科简体中文纯文本语料约 528M。

  从一年前的计算语言学作业开始,我一直没明白,为什么我写的二元语法分词要比一元语法差。两天来我仔细分析了一下之前的实验细节,发现二元语法分词要超过一元语法,可以有两种方式:1.超大的语料;2.强大的平滑算法。

  实验采用北大人民日报1-6月语料,大约700万字,选其中90%作为训练数据,另外10%作为测试数据。先看下实验结果:

分词方法准确率召回率F值
最大正向匹配0.90400.91890.9114
一元语法0.92800.95020.9389
二元语法(+1平滑) 0.90930.92760.9184
二元语法(+eps平滑)0.91020.95290.9311
二元语法(删除插值)0.93170.96150.9463

继续阅读