资讯动态

WordNet语义相似度计算实战:NLTK路径与信息量算法详解

发布时间:2026/10/14 6:28:16 来源:尧图企业网站定制
做文本匹配时遇到过一个很典型的场景两句话表达的是同一个意思字面上却一个重合词都没有。比如这台笔记本的电池续航很能打和这台电脑的电力表现持久关键词完全对不上交给基于字面重合的Jaccard或余弦相似度得分几乎为零。后来把WordNet引入用词义相似度做了一层语义兜底情况才明显改善。这篇笔记就完整记录WordNet相似度计算的原理、代码和踩坑过程不需要装模型不需要GPUNLTK加一个离线词库就能跑通适合刚接触NLP语义计算、想在具体任务里快速验证语义相似度效果的读者。我尽量写得像平时工作笔记的风格先讲清楚WordNet的数据结构再解释几种相似度算法为什么这么设计最后给出完整可复现代码和一堆实测中的经验教训。如果你只是想抄一段能用的代码可以直接跳到第5节但我建议还是把第2节的树结构看明白否则调参时很容易一头雾水。1. 为什么要关心词义相似度从一句话里的歧义说起1.1 字面匹配解决不了的问题自然语言处理里相似这个词其实有两层意思。一种是字面相似比如苹果手机和苹果手机壳在字符层面有很高的重合度用编辑距离、BOW向量夹角就能算出来。但另一种是语义相似比如苹果和果汁在字面上毫无关系可如果上下文是水果它们共享很多语义特征反过来苹果和香蕉看似词形完全无关语义上却很近。问题在于绝大多数业务场景需要的是语义相似却只有字面匹配的工具。早期我做文本去重、相似问题聚类时吃的就是这个亏用户反馈我的耳机充不进电和耳机无法充电分词之后没有任何公共词传统无监督方法直接判成不相似。这种问题靠堆同义词表可以缓解一点但同义词表维护成本高、覆盖面有限今天加一对明天又有新说法根本追不上真实语言的多样性。1.2 WordNet给了我们一个词义坐标系WordNet是一本电子化的词汇语义词典思路是把每个词按照词义而不是词形组织起来。同样一个bank在river bank河岸和financial bank银行里被当作两个不同的词义节点。每个词义节点被称为一个synset同义词集表示一组在特定语义下可以互换的词。真正让WordNet有价值的地方在于它把这些synset连接成了一个网络名词有上位词/下位词的层级分类树比如狗→犬科动物→食肉动物→哺乳动物→脊椎动物→动物→实体一直可以上溯到最顶层的entity动词有蕴含关系和反义关系形容词有反义和相似关系。这个层级树正是后面所有相似度算法的基础——两个词义在树上的距离越近语义就越接近。我第一次看WordNet结构时最大的感受是原来语义相似度问题可以转化成一个图论问题。过去觉得语义这东西虚无缥缈一旦有了明确的节点和边就能用路径长度、节点深度、祖先节点重合度等指标去量化它。2. 认识WordNet的数据结构同义词集与词汇树2.1 synset、lemma和词义编号NLTK里访问WordNet非常简单两个核心概念先记牢synset一个词义节点标注格式为词.词性.编号例如dog.n.01表示名词dog的第一个词义。lemmasynset下的具体词形一个synset可以包含多个lemma比如dog.n.01除了dog还可能包含domestic dog、Canis familiaris。用wn.synsets(dog)看看结果你会发现返回的是一串不同词义的synset狗这个动物、狗肉、某个星座、以及动词跟踪/纠缠等。这里的教训是大多数英语单词都是多义词千万别默认synsets()[0]就是你要的那个词义要根据上下文或词性先做筛选。from nltk.corpus import wordnet as wn dog_synsets wn.synsets(dog) for ss in dog_synsets: print(ss, --, ss.definition())输出大致是这样dog.n.01、a member of the genus Canis...dog.n.02、a dull unattractive unpleasant girl or woman这个义项在语料库里也真实存在再到dog.v.01表示跟踪。看到这里你应该就明白了形容相似度之前必须先锁定具体是哪个词义。2.2 上位词与下位词构成的层级树每个synset都有hypernyms()上位词和hyponyms()下位词方法。沿着hypernym反复向上走就能画出词汇树。dog wn.synset(dog.n.01) path [] current dog while current.hypernyms(): path.append(current.name()) current current.hypernyms()[0] path.append(entity) print( - .join(path))运行后你会看到一条清晰的分类链dog.n.01 - canine.n.02 - carnivore.n.01 - placental.n.01 - mammal.n.01 - vertebrate.n.01 - chordate.n.01 - animal.n.01 - organism.n.01 - living_thing.n.01 - whole.n.02 - object.n.01 - physical_entity.n.01 - entity.n.01。这个链条就是狗的语义位置。名词树的深度通常比动词树深得多形容词又不一样它没有真正的上位词树更多是反义和相似簇结构。这个差异直接影响相似度算法在名词上效果好、在动词上勉强、在形容词上基本不可用的实际表现。2.3 最低共同上位词LCS与算法的基础如果把词汇树想象成一棵家族谱系树狗和狼的共同祖先是犬科动物狗和猫的共同祖先是食肉目或更高层的哺乳动物。这个最低共同上位词Least Common SubsumerLCS是理解后续算法最关键的中间量。两个词义离LCS越远共同点越少两个词义离LCS越近直接共同属性越多LCS在树中的深度反映了两个词共享的那部分语义有多具体。后面讲的path_similarity其实只用了彼此距离这一个信息而wup_similarity同时看重彼此距离和共同祖先的深度Lin和Jiang-Conrath则是用信息量替换了单纯的路径距离。所以现在先把LCS这个概念焊死在脑子里。3. 环境准备与第一行代码NLTK接入WordNet3.1 安装NLTK与下载语料数据环境准备唯一的坑是数据下载。先安装NLTKpip install nltk然后下载WordNet相关数据import nltk nltk.download(wordnet) nltk.download(omw-1.4) # 多语言词网扩展 nltk.download(wordnet_ic) # 信息量统计语料后面IC算法要用如果网速慢可以在官网单独下载数据包放到本地nltk_data目录NLTK会自动识别。这里建议一次把wordnet_ic也下载了因为基于信息量的算法在代码里很容易因为缺这个数据集而报错我初学时被这个坑卡过好几次。3.2 基础API定义、例句、样例拿到synset之后最常用的三个方法是definition()、examples()和lemma_names()。它们能帮你确认自己是否选中了正确的词义。dog wn.synset(dog.n.01) print(dog.definition()) # a member of the genus Canis (probably descended from the common wolf) print(dog.examples()) # [the dog barked all night] print(dog.lemma_names()) # [dog, domestic_dog, Canis_familiaris]我的习惯是先把候选synset的definition打印出来看一眼。尤其是做数据标注或规则筛选的时候这一步能避免60%以上的词义选错问题。3.3 跑通第一个相似度计算准备工作完成后核心代码其实只有几行dog wn.synset(dog.n.01) cat wn.synset(cat.n.01) print(dog.path_similarity(cat)) print(dog.wup_similarity(cat))dog.n.01和cat.n.01之间的path_similarity输出约0.2wup_similarity约0.86。两个数字差这么多不是bug而是算法设计不同后面第4节会详细说明。先用这行代码确认你的环境没问题再说其他。4. 相似度算法详解路径度量与信息量度量4.1 path_similarity纯靠树上距离path_similarity的定义很简单两个synset在词汇树上的最短路径长度边的数量加1然后取倒数。公式similarity 1 / (shortest_path_length 1)如果狗和猫的最短路径长度是4得分就是1/5 0.2。这个指标最直观也最好解释适合给完全不懂NLP的业务方演示语义距离这个概念。但它有两个明显短板。第一它只依赖路径长度忽略了路径所在的深度——在树的深层距离为3和靠近根节点处距离为3语义上的精细程度完全不同。第二不同词性的节点之间无法计算因为它们在两棵不同的树里没有通路返回None。用生活化类比来理解path_similarity就像计算两个地铁站的直线距离完全不考虑你在市中心还是郊区。市区里隔一站距离很近郊区隔一站可能相隔好几公里它统统按一站计。4.2 wup_similarity引入LCS深度做加权wup_similarityWu-Palmer修正了上面只看距离不看深度的问题。它的公式是score 2 * depth(LCS) / (depth(synset1) depth(synset2))其中depth指从根节点到当前节点的边数。这个公式的直观含义是两个词义共享的那部分LCS越深、越具体它们就越相似同时各自离LCS越远则越不相似。还是拿狗和猫举例。它们的LCS可能是某个食肉动物节点深度大约在6~7层而狗和猫各自的深度在10层左右算出来的分数在0.85上下。也就是说Wu-Palmer得分天然偏高因为它把共同祖先的具体程度也算进了相似性里。实际使用中wup_similarity是几个算法里最温和的适合做第一层粗筛。4.3 lch_similarity考虑整棵树规模lch_similarityLeacock-Chodorow公式是score -log( shortest_path_length / (2 * total_depth) )这里total_depth是整棵词汇树的最大深度。除以最大深度的意义在于路径长度需要放在整棵树的规模里去归一化。同样是路径长度为3在一棵深度总共只有5的树里和深度总共20的树里相对距离完全不同。这个分数范围大概是0到3.6左右数值越大越相似。它和path_similarity最大的区别就是多了分母2*total_depth让结果跟树的整体规模有了解释关系。跨语料、跨版本比较时lch会比path稳定一些因为归一化了树的规模。4.4 基于信息量的算法resnik、lin、jiang路径类算法有个天然局限词汇树里每条边都等权重但现实中动物→哺乳动物和哺乳动物→犬科动物包含的信息量完全不同。基于信息量Information ContentIC的算法就是为了解决这个不均衡问题。一个synset的信息量定义为IC(s) -log p(s)其中p(s)是这个synset在语料库中的出现概率。越常见的词出现概率越高IC值越小携带的信息量越少反之越特异的词IC越大语义更具体。resnik_similarityIC(LCS)。只看两个词共同祖先携带多少信息量。缺点是没有考虑两个词本身的IC。lin_similarity2 * IC(LCS) / (IC(s1) IC(s2))。用两个词的信息量总和做归一化输出在0到1之间。jiang_conrath_similarity先算距离IC(s1) IC(s2) - 2 * IC(LCS)如果定义成相似度通常是距离的倒数或补数NLTK里直接调用jcn_similarity得到的是相似度形式。NLTK里使用这些算法时需要传入一个信息量语料字典from nltk.corpus import wordnet_ic brown_ic wordnet_ic.ic(ic-brown.dat) dog wn.synset(dog.n.01) cat wn.synset(cat.n.01) print(dog.res_similarity(cat, brown_ic)) print(dog.lin_similarity(cat, brown_ic)) print(dog.jcn_similarity(cat, brown_ic))ic-brown.dat是基于Brown语料统计的信息量数据ic-semcor.dat则是基于SemCor数据。不同语料版本对高频词、低频词的估计权重不同同一组词对的得分会有差异这一点我们在第6节还会细说。4.5 算法选择的核心判断把算法放一起对比大概是这样算法核心依据得分范围适合场景path最短路径长度0~1快速演示、粗筛wup最短路径LCS深度0~1温和排序语义近义词对区分弱lch路径归一化到树深度0~3.6左右跨版本/跨语料对比resnikLCS的信息量无固定上限捕捉共同祖先的语义信息量lin信息量配比0~1综合共同信息与各自信息jcn信息量距离无固定上限距离越小越相似通常趋势更陡我的经验如果直接做语义聚类先用wup或lin配合阈值过滤如果做近义文本匹配lin比path更能区分猫狗0.87左右和猫车0.4左右的差异如果只是给业务方看个直观数值、减少解释成本path_similarity最省事。5. 完整代码实战与结果分析5.1 一组完整的多算法对比实验写过无数遍相似度代码之后我总结了一个比较稳的写法先把要对比的词对列出来再逐个算出所有算法的分数最后汇总成表格。这样可以避免在不同脚本里反复看零散输出。from nltk.corpus import wordnet as wn from nltk.corpus import wordnet_ic from pprint import pprint brown_ic wordnet_ic.ic(ic-brown.dat) # 故意选名词名词树最完整效果最稳定 pairs [ (dog, cat), (car, automobile), (coffee, tea), (car, coffee), ] for w1, w2 in pairs: ss1 wn.synsets(w1)[0] # 暂时偷懒取第一个词义 ss2 wn.synsets(w2)[0] row { pair: f{w1}-{w2}, path: ss1.path_similarity(ss2), wup: ss1.wup_similarity(ss2), lch: ss1.lch_similarity(ss2), lin: ss1.lin_similarity(ss2, brown_ic), res: ss1.res_similarity(ss2, brown_ic), jcn: ss1.jcn_similarity(ss2, brown_ic), } pprint(row)实测结果大致如下不同NLTK版本存在细微浮动dog与catpath约0.2wup约0.86lin约0.87。car与automobile多个算法都接近1因为它在WordNet里很可能属于同一个synset即使不是同一个synset路径也极短。coffee与tea介于dog-cat和car-automobile之间。car与coffeepath只有0.1左右wup降到0.4左右lin在0.3左右显著低于语义相关组。有个值得注意的现象wup和lin在近义词对上分数普遍高于path因为它们都考虑了共同祖先的深度。coffee和tea能到0.7以上就是因为它们在饮料/刺激物这个具体层级有很多共享语义。这一点在业务中很有用如果你想过滤有点关联但不太一样的词对推荐用path如果你想尽量覆盖语义相近的说法推荐用wup。5.2 多义词处理别只取第一个synset上一节代码里偷懒用了synsets(w)[0]实际项目里这是个大坑。比如bank的第一个词义可能是河岸但你要匹配的语料里全是银行的含义。一旦选错词义相似度结果会完全失真。更稳妥的做法是枚举两个词的全部synset组合取最大值或取综合阈值以上的最佳组合def max_similarity(w1, w2, sim_func, *args, **kwargs): best None for ss1 in wn.synsets(w1, posn): # 按词性收敛效果更好 for ss2 in wn.synsets(w2, posn): score sim_func(ss1, ss2, *args, **kwargs) if score is None: continue if best is None or score best: best score return best print(max_similarity(bank, money, lambda a, b: a.wup_similarity(b)))如果业务语料有明显的领域倾向比如金融文本最好先根据词频或上下文把每个词映射到固定synset再做相似度计算。否则取所有组合最大值可能会把多义词的无关义项也算进去导致风马牛不相及的两个词碰巧因为某个罕见义项获得高分。5.3 词性标注与词形归一是必要前置WordNet的相似度计算严格限制在同一词性的树内。所以正确流程是先做词性标注POS tagging再做词形还原lemmatization最后才能查synset。NLTK里词形还原推荐WordNetLemmatizerfrom nltk.stem import WordNetLemmatizer lemmatizer WordNetLemmatizer() print(lemmatizer.lemmatize(running, posv)) # run print(lemmatizer.lemmatize(cars, posn)) # car注意lemmatize需要你告诉它词性否则它默认当名词处理running会还原成running而不是run。这也是一个最常见的低级陷阱。我通常的做法先用NLTK的POS tagger标注然后根据标签把VB/VBD/VBN/...映射成vNN/NNS/...映射成nJJ/JJR/...映射成a最后才做lemmatize和synset查询。顺序一旦反了效果立马打折。6. 真实业务中的坑与调参经验6.1 跨词性返回Nonedog.path_similarity(run)会返回None因为名词树和动词树之间没有通路。这不算bug而是设计使然。实际代码里一定要对None做兜底处理否则一不留神就变成TypeError或者在pandas里出现整列空值。最简单的方式是统一封装一层def safe_sim(ss1, ss2, methodwup, icNone): func_map { path: lambda: ss1.path_similarity(ss2), wup: lambda: ss1.wup_similarity(ss2), lch: lambda: ss1.lch_similarity(ss2), lin: lambda: ss1.lin_similarity(ss2, ic), jcn: lambda: ss1.jcn_similarity(ss2, ic), } return func_map.get(method, func_map[wup])()6.2 信息量语料选择直接影响IC算法结果NLTK提供多份IC语料文件包括ic-brown.dat、ic-semcor.dat、ic-bnc-resnik.dat等。它们基于不同语料库统计而来高频词和低频词的分布差异很大。拿coffee和tea这类日常词做测试用Brown语料和用SemCor语料算出来的lin值可能相差0.1以上。建议在同一次实验里固定同一个IC文件别混着用。如果业务语料和通用英语语料差异巨大比如全是医学、金融领域文本IC值必须重估才有意义这时候就别硬用WordNet自带数据了。6.3 词形覆盖不全找不到synset怎么办WordNet的词汇覆盖以日常英语为主专业词汇、生僻词、专有名词经常查不到synset。比如某些新造词、品牌名synsets()返回空列表。我的处理策略是分三层降级先查原词查不到就查lemma后的词形再查不到就尝试用编辑距离匹配最接近的已有lemma复杂度高平时很少用。如果三层都失败直接返回一个预设的默认值或放弃计算。不要因为个别词查不到而让整个流程崩溃。6.4 性能优化穷举组合不可取多义词全组合枚举在词对数量达到万级以后会非常慢因为每个词的synset数量从几个到几十个不等两层循环乘起来是指数膨胀。有两个优化思路预过滤先按lemma_names()筛选候选synset只保留包含该词形lemma的节点能砍掉不少无关义项。缓存计算结果用functools.lru_cache装饰计算函数同一个(w1, w2, method)组合只算一次批量任务能提速数十倍。from functools import lru_cache lru_cache(maxsize100000) def cached_max_similarity(w1, w2, methodwup): ss1_list [s for s in wn.synsets(w1, posn)] ss2_list [s for s in wn.synsets(w2, posn)] return max_similarity_between_synsets(ss1_list, ss2_list, method)6.5 什么时候别用它WordNet相似度虽好但边界明显它不会处理苹果手机这种复合词与香蕉的语义关系也无法理解形容词在特定语境里的情感色彩更不能感知语境导致的临时隐喻义。如果你的任务是短文本语义匹配、FAQ问答、同义句改写它可以作为特征之一搭配编辑距离或向量模型使用但如果要处理长文本语义相似、需要结合上下文理解请老老实实换Bert类模型。WordNet更像是一个先验知识底座而不是万能的语义万能胶。实战中一个比较合理的组合是先用WordNet相似度做粗筛把明显语义无关的文本快速过滤掉再用模型跑精排。这样既控制了计算成本又避免了纯规则方法的覆盖不足。最后再分享一个实战经验在做相似度阈值调优时不要只盯着某个算法的绝对值要结合任务看排序稳定性。wup和lin得分普遍偏高阈值取0.7可能正合适path得分偏低阈值0.3也可能过于宽松。我习惯每轮迭代都输出同一组基准词对的得分分布先确保基准词对的相对顺序稳定再调阈值。这个习惯帮我避过很多换了一批数据分数全漂移的坑。WordNet相似度计算本身不复杂真正决定效果高低的往往是你对数据结构的理解和对词义选择的把控。这篇笔记先到这里后续我会继续整理它的变体用法和与其他语义模型的融合方案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑