评估词向量这件事,我一开始以为是拿个相似度任务跑一下、看看准确率就行,后来发现远远没有这么简单。词向量像是一个基础工具,你说它好不好用,其实取决于你到底拿它来做什么。如果只是看两个词的余弦相似度能不能匹配上同义词,那很多模型都能做到八九不离十;但一旦放到真实的下游任务里,比如情感分类、命名实体识别、语义匹配,词向量的微小差异都会被放得很大。
当时我学到第12篇,正好是把词向量评估、Skip-gram的缺点、Subword三个问题串到一起的关键节点。这篇笔记就想把这个阶段的思考完整地整理出来,包括怎么评估词向量、Skip-gram到底弱在哪里、Subword为什么能补上这些短板,以及我自己在实操中踩过的坑和验证过的结论。适合正在系统学NLP、尤其是学到词表示这块的朋友参考。
1. 词向量评估:先想清楚你评估的是"语言规律"还是"下游任务"
评估词向量,本质上是在回答一个问题:这些向量到底有没有把我们关心的语言规律编码进去?但"语言规律"这个词太大了,所以业界分成了内部评估和外部评估两条路线。两条路线目的不同,结论差别也很大。
1.1 内部评估——相似度与类比任务怎么看
内部评估不需要跑任何复杂的下游模型,直接对词向量本身做计算。最常见的有两类任务:相似度任务和类比任务。
相似度任务的做法是:拿一组人工标注好相似度分数的词对,比如"猫-狗"打了8分、"猫-汽车"打了1分,然后计算词向量的余弦相似度,看它和人工分数相关多少,常用Spearman相关系数来衡量。这个任务看起来很直观,但我实际跑下来发现一个问题:不同的相似度数据集,侧重点完全不同。像WordSim-353这类数据集更偏"主题相关性",而SimLex-999则更严格要求语义相似性。同一个词向量在WordSim-353上表现很好,在SimLex-999上可能直接掉十个点,原因就是很多词向量把"经常一起出现"和"语义相近"混为一谈。
类比任务则是经典的"国王-男人+女人=女王"这类推理,计算的是向量空间中的平行偏移关系。评估指标一般是准确率,看看模型能不能从候选词里找出正确的结果。我自己的体会是,类比任务比相似度任务更容易受高频词干扰,而且对语料质量非常敏感。如果语料里"国王""女王"这种词出现频率差距过大,偏移向量就会歪掉。
还有一种很容易被忽略的内部评估方法:向量空间的性质检验。比如各向同性,好的词向量在空间中的分布不应该全都挤在一个锥形区域里,否则余弦相似度会整体偏高,区分度很差。这个性质对后面做向量运算、做聚类都会有影响。
1.2 外部评估——真正决定词向量价值的试金石
外部评估就是把人家的词向量作为输入特征,喂给一个下游任务模型,看最终效果。比如做文本分类,把句子里的词替换成词向量,然后接一个分类模型,看F1值。
外部评估的好处是结果直接反映实际使用价值。但它也带来一个麻烦:你很难判断效果的提升到底来自词向量本身,还是来自下游模型的结构。我试过一个很典型的场景:同一个词向量,接一套简单的线性分类器和接一个深层双向LSTM,效果差距非常大。这时候你评估的其实已经不是词向量了,而是整个模型组合。
所以我自己在实践中养成了一个习惯:先做内部评估,快速筛掉明显不好的词向量;再做外部评估,在固定的下游模型上进行对比。并且外部评估的时候,下游模型要固定参数、固定随机种子、固定训练轮数,否则词向量之间的差异会被模型训练的随机性掩盖掉。
另外还有一个值得尝试的技巧:冻结词向量 vs 微调词向量。所谓冻结,就是在下游模型训练过程中,词向量层的参数不再更新;微调则是把词向量也作为可训练参数。我见过很多人只测了微调的情况,结果词向量之间的差距被磨平了,得出了"所有预训练词向量效果都差不多"的结论,这其实是不严谨的。正确的做法是两种模式都测一测,才能更全面地判断词向量本身的信息量。
2. Skip-gram的三个硬伤:被很多人忽略的边界条件
Skip-gram是Word2Vec里最常用的模型,基本思路是用中心词预测上下文词,把词的共现信息压缩到低维向量里。它效果好、训练简单,但它的缺点也很明显,而且是那种在真实场景里会直接限制你应用的硬伤。
2.1 静态向量解决不了一词多义
这是Skip-gram最被诟病的点之一:每个词在训练完之后只有一个向量。但现实中一个词往往有多个含义,比"苹果"可以指水果,也可以指科技公司,还可以是电影、歌词里的意象。Skip-gram会把所有这些含义混合在同一个向量里,结果是哪个含义都不太像。
我自己对这个问题的感受非常深。一开始我拿到一个预训练词向量,查了一下"苹果"的最近邻,发现排在前面的是梨、香蕉、水果,然后又有一批和科技相关的词,再往后是一些和品牌相关的词,整个向量被拉到了一种"水果和科技的平均值"的位置上。这种情况做相似度检索还能勉强接受,但如果做语义匹配或者问答,就很容易出错。
为什么Skip-gram会产生这个问题?因为它从头到尾只有一个向量表示一个词,训练时上下文是水果语境的天天被更新,上下文是科技语境的时候也被更新,同一个向量被迫在两种信号之间妥协。解决方向有两个:一个是ELMo、BERT这类基于上下文的动态表示,每个词在不同句子里有不同向量;另一个是在静态词向量层面做多义分解,把一个词拆成多个sense向量。后者在实际落地中用的不多,主要还是因为动态表示的效果碾压。
2.2 未登录词与低频词是最大痛点
未登录词,就是词典里根本没有的词。Skip-gram训练完成后,词汇表就固定了,新词、拼写变体、专有名词一概不认识。你训练的时候语料里没有"区块链"这个词,那你之后用它做语义搜索就搜不到任何东西,因为这个词压根没有向量。
低频词的问题稍微隐蔽一点,但同样严重。因为Skip-gram更新词向量依赖这个词作为中心词或上下文词出现在语料中,如果某个词只出现了两三次,它的向量会非常不稳定,基本是被周围的几个词随便拉来拉去,没有任何泛化能力。我做过一个简单的统计实验:在中文语料上训练Word2Vec,把所有频次低于5次的词挑出来,看它们的最近邻,结果几乎全是噪声,和这些词本身没有任何语义关系。
这里还要提一下采样策略的影响。Skip-gram训练时会对高频词做下采样,减少"的""了""是"这类词的干扰,这个设置在大多数情况下是好的,但也会导致一些中频词被过度下采样。我在调参时遇到过一种情况:把下采样阈值调得太高,结果很多有语义价值的词向量质量严重下降,因为它们在训练中被主动忽略的次数太多了。
2.3 词级别的粒度隔绝了形态学信息
英文里的"run""runs""running"在Skip-gram看来是完全不同的词,尽管它们共享同一个词根。中文里的"猫""猫咪""小猫"也完全独立,各个向量之间没有任何形态学上的关联。这导致模型在数据量不够的时候,很难把这些形态相关的词向量拉近。
形态学信息的重要性在于泛化能力。人类看到"跑""跑着""跑了"能立刻知道它们是同一个动作的不同形式,但Skip-gram需要大量语料才能学到这一点。对于英语这类形态丰富的语言,这个缺点尤其致命。而且即使语料够多,也只是把高频的形态变体学好,那些少见的、生僻的变体依然学不好。
这也引出了词粒度表示的根本问题:词是离散的符号,如果你只给每个符号分配一个向量,符号之间的内在关联就完全取决于它们在语料中的共现统计,而不是它们的构成方式。这等于主动放弃了语言本身的规律性。Subword的思路恰恰是把"词"这个基本单位拆开,让模型从更细的粒度上去学习。
3. 从词到子词:Subword为什么是绕不开的方案
Subword不是一个具体模型,而是一类方法的总称。核心思想很简单:与其把词当作不可拆分的最小单位,不如把词拆成更小的片段,然后让模型从这些片段的组合中去学习词的表示。这样做的直接好处是:词表变小、未登录词可以被拆解、低频词也能借助子词获得向量。
3.1 核心思路:用片段解决整词的问题
打个比方。以前你要在一本书里找所有提到"李明"的地方,如果书里有一千万个名字,你就不可能全记住。但如果你把名字拆成姓和名,只需要记几百个姓、几千个名,就能组合出几乎所有新名字。Subword就是这个思路,它把词表从几百万压缩到几万甚至几千,每个词都变成若干子词片段的组合。
这个思路解决了我前面提到的未登录词问题:哪怕一个词在训练时完全没见过,只要它的字符片段出现在子词词表中,我就能拼出一个近似的向量。低频词也有救了,因为虽然"艰难困苦"这整个词出现得很少,但"艰难""困苦"这些子词出现得可能很多,它们的向量质量会好很多,组合出来之后,整词的向量质量也随之提升。
Subword的引入还改变了OOV的处理方式。以前遇到一个没见过的词,要么直接扔掉,要么随便初始化一个随机向量,要么用UNK标记代替。现在则可以按规则切分成子词序列,正常送入模型。这一改进在文本分类、机器翻译、命名实体识别上都带来了很明显的收益。
3.2 FastText的字符n-gram方案
在Subword这个方向上,FastText是非常有代表性的工作。它本质上和Skip-gram的框架一样,但把每个词表示成若干字符n-gram向量的叠加。例如"apple",除了完整的"apple"之外,还会拆出"app"、"ppl"、"ple"、"appl"、"pple"这些字符片段,词向量就是这些片段向量的总和。
我自己实际用的时候,一开始会把字符n-gram的尺寸设成默认值3-6,后来发现这个选择需要根据语料类型去调。如果是多语言场景,形态变化比较丰富的语言(比如德语、芬兰语),窗口最好大一点;如果是中文这种字符本身就携带很多信息的语言,其实直接用一个字符都能有不错的效果,因为中文字符数量远小于词数量,而且每个字符都有独立含义。
FastText另一个实用价值在于可以训练出词表外词的向量。测试的时候碰到一个训练时没出现过的词,仍然可以按字符n-gram把它拆开,聚合出对应的向量。这一特性非常实用,我见过不少用FastText做文本相似度的项目,一遇到新词出现,其他模型直接哑火,FastText还能照常给向量结果。
不过FastText的代价也很明显:训练和存储开销显著增加。每个词会拆出多个n-gram,模型需要学习的片段数量是原来的几十倍。训练速度会变慢,模型文件也会变大很多。我拿同样的中文语料分别训练Word2Vec和FastText,模型文件体积大概差了5到8倍。如果你有海量词汇和大规模语料,硬件成本是个需要提前估算的问题。
3.3 BPE、WordPiece、Unigram的切分思路
除了FastText的字符n-gram之外,还有一类更精细的子词方法,典型的代表是BPE、WordPiece和Unigram。它们不直接使用固定长度的字符n-gram,而是从语料中学习一套最优的切分规则,把词切成语义上更有意义的片段。
BPE(Byte Pair Encoding)的思路是迭代合并出现频率最高的字符对。初始时语料是所有字符的序列,然后反复把最常出现的相邻字符对合并成一个符号。比如"low""lower""lowest"经过BPE处理后,可能会变成"low"加后缀,这样"low"这个片段就能在很多词里共享。实际操作中,BPE的合并次数是一个超参数,合并次数越多,子词粒度越大,词表也就越大;合并次数太少,则很容易拆到单字符级别,丢失词级语义。
WordPiece和BPE很像,只不过它在决定合并哪些字符对时,不是单纯看共现频率,而是看合并前后语言模型概率的提升幅度。BERT用的就是WordPiece。这个细节上的差异,让WordPiece在切分连贯性上通常比BPE好一些。Unigram则走了完全相反的路线:一开始把词切到最细,然后用语言模型不断剪掉那些不重要的子词,最终剩下一个精简的子词表。
这三种方法在实际落地时各有各的偏好。机器翻译领域BPE依然是主力;BERT系列主要用WordPiece;一些较新的多语言模型比如XLM-R则用Unigram。它们训练完之后,每个词都会被切成一个或多个子词,然后子词送入模型进行编码。你可以把BPE理解为一种更聪明的、由数据驱动的分词方式,它比固定字符n-gram更灵活,但它也有一个让很多人头疼的问题:同一个词在不同语境下可能被切成不同的片段组合,不过这在绝大多数深度学习任务里是可以接受的。
4. 实战对比与选型:什么时候该用哪种方案
理论说了一堆,还是要落到实际场景里看效果。我拿一个小规模的中文新闻语料做了几组对比实验,分别训练了纯词粒度的Word2Vec、固定n-gram粒度的FastText,以及加了BPE子词编码的词表示模型。这里不涉及具体的模型名称,只分享我观察到的规律和选择逻辑。
4.1 我在小规模语料上的实验对比
语料规模大概只有几百万条新闻标题,量不算大。我先用Word2Vec的Skip-gram跑了一版,再用FastText默认的3-6字符n-gram跑了一版,最后把新闻标题用BPE切词之后重新训练词向量。
在相似度任务上,三者其实没有拉开特别大的差距,Word2Vec略好于FastText,BPE略差于前两者。这个结果有点反直觉,后来我想明白了:相似度任务主要依赖高频常见词的语义判断,这些小词的共现信息已经足够充分,Subword带来的增量优势在这些词上体现不出来。
但在未登录词和低频词上,差距就非常明显了。我特意构造了一些训练语料中从未出现的词,比如新出现的网络热词、生僻地名,Word2Vec全部无法给出向量,FastText通过字符n-gram能给出一个大致合理的向量,BPE在碰到能切分成已知子词的情况下也能处理,但一旦切出未知子词就只能用UNK兜底。
另一个观察在文本分类任务上。我固定了下游模型结构和训练参数,分别用三种词向量作为输入。结果FastText和BPE在小类别的分类准确率上都明显高于Word2Vec,因为那些小类别里的关键特征词普遍低频,Word2Vec学不好这种词的向量,而Subword方案可以借助子词信息对低频词得到相对稳定的表示。
4.2 不同任务下的选型建议
通过这几轮实验,我逐渐形成了一个比较实用的选型经验。如果你处理的是高资源语料场景,词表覆盖已经非常充分,几乎没有未见过的词,同时你又非常在意训练速度和对外发布的模型文件大小,那Skip-gram或者纯词粒度的向量依然是一个性价比很高的选择。
如果语料规模有限、领域词汇丰富、新词不断出现,比如社交媒体文本、商品评论、多语言混合场景,那我建议直接用FastText。它对OOV的容忍度最高,实现也最简单,几乎不需要额外的文本预处理,直接把空格或分词后的文本喂进去就行。
如果你的任务本身是一个端到端的深度学习流水线,后面还准备接Transformer结构,那就不要单独训练词向量了,直接使用BPE或WordPiece去做Subword编码,让模型从子词级别开始学习。这种情况下,词向量的训练和下游模型是联合的,分开预训练反而多此一举。
我特别想强调一点:词向量的选型要和语料规模、任务类型强绑定,没有一个方案能在所有场景下都赢。每当有人说"我用了某个词向量效果很差"的时候,我发现大多数时候不是词向量本身的问题,而是场景选错了。比如拿一个通用领域训练的词向量去做某垂直领域的专业术语检索,效果不好是理所当然的。
5. 学习路上的几个常见误区与避坑经验
这个环节我踩过不少坑,有些坑看起来特别低级,但在实际跑实验的时候就是会掉进去,而且不容易发现。
5.1 评估词向量时最容易犯的几个错
第一个错误是只在一个数据集上做评估就下结论。我之前提过WordSim-353和SimLex-999的差异,如果只跑WordSim-353,你可能会高估所有词向量的语义相似性;只跑SimLex-999,又会低估词向量的主题相关性。我建议至少选两到三个评估集,分别覆盖相似度和类比任务,再结合一个外部下游任务。
第二个错误是忽略词频分层。我见过不少评估报告把词向量在全体词汇上的指标写成一个平均值,这个平均值其实掩盖了低频词和高频词之间的巨大差异。正确做法是把词按频次分段,分别计算每个频段上的评估指标。这样你就能看出某个词向量到底是在高频词上表现好,还是在低频词上也有泛化能力。
第三个错误是过度相信"加号减号"的类比结果。经典类比任务"国王-男人+女人=女王"看起来很漂亮,但实际操作里这种结果对词向量选择、超参设置甚至随机种子都很敏感。我在同样参数下跑很多次,准确率可能波动好几个点。所以在评估时不要只看单次结果,多跑几个随机种子取平均,能更稳定地反映模型真实水平。
5.2 Subword落地时的细节坑
我自己在把Subword方案落地到实际项目时,踩过几个比较有共性的坑。
第一个是子词表和下游任务的词汇表没有对齐。比如你用了BPE切词之后训练了一个语言模型,但下游任务里你还要照原来的方式处理输入,结果同一个词在训练时切成一种组合,在推理时切成另一种组合,造成线上线下不一致。解决办法是保证全流程用同一个分词器和同一份词表,训练和推理都走同一套代码逻辑。
第二个是子词粒度设置不当。BPE的合并次数太少,词会被拆得太碎,单个子词失去语义;合并次数太多,词表急剧膨胀,很多生僻词又被当作完整词处理,Subword的价值就没了。我个人的经验是,从默认参数出发,然后观察未登录词的比例,如果OOV比例还是很高的,就适当减少合并次数,保留更多细粒度子词。
第三个问题是存储开销。Subword词表虽然小,但合成词向量时需要为每个子词存储一个向量,再加上训练过程中保存的n-gram向量,模型体积会比纯词级别大很多。尤其是在移动端或内存受限的场景,需要考虑量化或者剪枝,否则模型根本塞不进部署环境。
还有一个容易被忽视的问题是:中文语料在应用Subword之前到底要不要分词?我的经验是,如果使用FastText式的字符n-gram,那就不要先把中文切成词,直接按字符处理就行,因为切词之后反而丢失了字符之间的组合信息。如果使用BPE或WordPiece,则需要把中文先按字符预处理,然后交给子词工具去学习切分,不要先手动分词,否则BPE能学到的组合模式会受限。
5.3 给同样在系统学习NLP的人一些建议
我的学习路径接近这个系列的第12篇,也就是到了词向量这个主题的中后段。走到这里的时候,我的一个核心体会是:词向量不是NLP学习的终点,它更像一个理解"表示学习"这个概念的切入点。
如果你也和我一样,刚学完词向量评估和Subword,想继续往下深入,我建议先把动态词向量的章节学完,尤其是ELMo和早期BERT的原理。它们解决的一词多义问题,恰好是Skip-gram和静态词向量的最大短板。把静态和动态的差异对比着学,你对整个NLP表示学习的理解会明显加深。
另外,我强烈建议你在学完原理后,找一份自己感兴趣的语料,亲手训练一个词向量模型,然后做可视化。把学过的词用降维算法降到二维平面里画出来,亲眼看一看同义词是不是聚在一起、类比关系是不是平行。这个做一遍的效果胜过读十篇论文。
我会尽量把每次实验的细节记录下来,包括数据规模、超参数、评估结果、踩过的坑。很多看起来不起眼的细节,在几个月后回头看往往会变成最有价值的参考。这也是为什么这篇学习笔记会写得这么细的原因。