☰
Python+NLTK:WordNet语义相似度计算详解
2026/10/14 23:29:42 网站建设 项目流程

做 Python 自然语言处理的人,迟早会撞上“两个词到底有多像”这个问题。这篇笔记(一)聊的就是 WordNet 相似度计算——用 NLTK 加载 WordNet,把词和词之间的语义关系量化为一个可比较的分数。你可能早就听说过 word2vec、BERT,但在很多小规模任务里,WordNet 这套老牌做法反而更稳、更快,而且每个分数都能解释清楚来龙去脉。这篇适合两类人:一类是刚入门、想知道怎么用 Python 算词相似度的新手;另一类是已经用向量模型但经常被无厘头相似结果坑过,想回到符号语义上找兜底方案的人。读完你至少能写出一段能跑的代码,也知道六个常用相似度算法之间到底差在哪里。

1. 先搞清楚:WordNet 到底在算什么相似度

1.1 它不是一个普通词典,而是一张概念网

很多人第一次用 WordNet,会误以为它就是一本机器可读的英语词典。不对。WordNet 的核心单位不是“单词”,而是“synset”,也就是一组同义概念的集合。比如dog.n.01这个 synset 里装着一系列表示“狗”这个动物概念的词条,而dog这个词本身可能同时属于多个 synset:可能是“犬科动物”,可能是“讨人厌的家伙”,也可能是“机械爪”。同一个词对应多个 synset,这就是一词多义在 WordNet 里的表示方式。

所以我一直把 WordNet 理解成一张“概念地图”。想象你要去一个陌生的城市,城市里的街道、商圈、区域被一层一层画出来:市中心下面分东区、西区,东区下面再分具体的小区。WordNet 差不多就是干这个的,只不过它画的是语义空间。hypernym(上位词)是往上一级走,hyponym(下位词)是往下一级钻。狗是哺乳动物的下位词,哺乳动物又是动物的下位词,这样一层一层,最终可以追溯到 WordNet 名词体系的最顶层节点。

理解这一点至关重要,因为所有基于路径的相似度算法,本质上都是在这张“概念网”里数两个 synset 之间隔了多少条边。你说“狗”和“猫”像不像,算法不看字面,而是去概念网里找它们的公共祖先在哪一层。

1.2 相似度、相关性和同一个词的不同词义

用 WordNet 之前,还得先把一个概念理清:相似度(similarity)和相关性(relatedness)不是一回事。“汽车”和“汽油”相关性很高,但“汽车”和“汽车”才是相似度拉满;“狗”和“猫”是相似的,因为它们属于同一个“食肉目/犬型亚目”这种层级分支。WordNet 这套 is-a 层级关系只擅长捕捉“同类程度”,不适合捕捉“搭不搭”。

另一个容易忽略的点是:在 WordNet 里,相似度永远是“synset 与 synset”之间的,不是“word 与 word”之间的。你让 nltk 计算dog和puppy,实际要做的是先把dog可能的几个 synset 和puppy可能的几个 synset 全部列出来,然后两两配对计算,再取最大值或做加权。这步说起来简单,却是之后所有坑的源头。很多新手代码只取了wn.synsets('bank')[0],结果拿“河岸”去和“钱”比相似度,怎么比怎么低。

2. 动手前:环境安装与数据准备

2.1 安装 NLTK 并下载数据

我用的是 Python 3 环境加 NLTK 3.x。安装本身不复杂:

pip install nltk

装完之后,需要下载 WordNet 本体数据、信息量统计数据,还有可选的多语言映射包。网上很多教程只写wordnet一个包,但后面一旦用res_similarity、jcn_similarity、lin_similarity这些基于信息量的算法,就会因为没有wordnet_ic数据报错。

import nltk nltk.download('wordnet') nltk.download('omw-1.4') nltk.download('wordnet_ic')

omw-1.4是多语言 WordNet 的开放镜像,做中文或跨语言任务时有用;只做英文相似度的话可以不装,但我建议顺手装上,免得以后换场景再回来补。wordnet_ic里面是几个预训练好的信息量统计文件,包括基于布朗语料库的ic-brown.dat、基于 SemCor 标注语料的ic-semcor.dat等。

下载的位置默认在用户目录的nltk_data下。如果在公司内网或离线环境,第一次下载失败很正常,可以直接到 NLTK 的 github 仓库把对应 zip 包手动放到~/nltk_data/corpora下,或者在代码里临时改nltk.data.path指向你自己解压的目录。这个细节我踩过不止一次,环境越“干净”越容易出问题。

2.2 先把词形这一关过了

WordNet 的词条是词元(lemma)形式,不会把cars当成一个独立词条,它记录的基准是car。所以算相似度之前必须先做词形还原,把running还原成run,把went还原成go。

NLTK 里用WordNetLemmatizer就能做:

from nltk.stem import WordNetLemmatizer lemm = WordNetLemmatizer() print(lemm.lemmatize('cars', pos='n')) # car print(lemm.lemmatize('went', pos='v')) # go print(lemm.lemmatize('running', pos='v')) # run print(lemm.lemmatize('better', pos='a')) # good

注意最后一个参数pos。默认的pos是名词,如果不传pos='v',running会被当成名词还原,结果还是running,而running的名词 synset 和动词run的 synset 是两套东西,相似度自然算不对。同理,形容词要传'a',副词传'r'。实际项目里我不会只做一次,而是写个小工具把几种词性都试一遍,优先取还原后长度最短的那个结果,后面所有代码都调它。

3. 最小可用代码:两个词算出相似度

3.1 拿到 synset 再看定义

核心入口是wn.synsets()。以dog为例:

from nltk.corpus import wordnet as wn dog_synsets = wn.synsets('dog') for syn in dog_synsets: print(syn.name(), ':', syn.definition())

输出会是一长串,比较典型的是:

dog.n.01 : a member of the genus Canis dog.n.02 : a dull unattractive unpleasant girl or woman ...

每个Synset对象都有name()、definition()、examples()、hypernyms()、hyponyms()、lemma_names()这些方法。拿到 synset 之后,先看一眼definition(),确认它到底是不是你想要的那个词义。这一步其实特别重要,因为它决定了后续所有相似度分数到底在比较什么。

有些情况你明确知道要哪个词义,可以直接用wn.synset('dog.n.01')跳过模糊搜索。比如做领域词典的时候,我经常手动指定 offset,而不是让程序在十几个 synset 里猜。

3.2 一个函数统一处理所有度量

因为相似度发生在 synset 之间,而一个词可能有多个 synset,所以我写了一个统一函数:先对输入词做词形还原,再枚举两个词的所有 synset 组合,尝试计算分数,过滤掉无效值,最后取最大值。

import math from nltk.corpus import wordnet as wn from nltk.corpus import wordnet_ic from nltk.stem import WordNetLemmatizer lemm = WordNetLemmatizer() semcor_ic = wordnet_ic.ic('ic-semcor.dat') def smart_lemm(word): candidates = [] for pos in ('n', 'v', 'a', 'r'): cand = lemm.lemmatize(word.lower(), pos=pos) if cand != word.lower(): candidates.append(cand) return min(candidates, key=len) if candidates else word.lower() def word_sim(word_a, word_b, method='wup', ic=semcor_ic): sa_list = wn.synsets(smart_lemm(word_a)) sb_list = wn.synsets(smart_lemm(word_b)) if not sa_list or not sb_list: return 0.0 scores = [] for sa in sa_list: for sb in sb_list: try: if method == 'path': v = sa.path_similarity(sb) elif method == 'wup': v = sa.wup_similarity(sb) elif method == 'lch': v = sa.lch_similarity(sb) elif method == 'res': v = sa.res_similarity(sb, ic) elif method == 'jcn': v = sa.jcn_similarity(sb, ic) elif method == 'lin': v = sa.lin_similarity(sb, ic) else: raise ValueError('unknown method: ' + method) except Exception: v = None if v is not None and math.isfinite(v): scores.append(v) return max(scores) if scores else 0.0

代码里那个try/except不是摆设。lch_similarity在词性不同的 synset 之间会直接抛异常;path_similarity有些场景返回None;jcn_similarity在分母为 0 时可能产生无穷大。统一兜底之后,这个函数才算能安全地在批处理里跑。

3.3 实测几个例子

有了word_sim,看几个例子就有感觉了:

print(word_sim('car', 'automobile', method='wup')) print(word_sim('dog', 'cat', method='wup')) print(word_sim('hot', 'cold', method='wup'))

car和automobile是同义词,它们会落到同一个 synset,wup是 1.0。dog和cat虽然不是同义词,但共享比较近的公共祖先,wup一般会落在 0.8 上下。hot和cold这种形容词对,wup 算出来往往很低,甚至可能为 0——因为形容词在 WordNet 里不是靠 is-a 树组织的,走路径相似度本身就很勉强。

这组例子很能说明问题:分数高低不是玄学,而是反映“概念网中两个节点的距离”。接下来就得把这六个算法分别拆开看。

4. 六种算法拆解:路径、深度、信息量

4.1 基于路径:path、lch、wup

path_similarity是最直观的。它的公式是:

similarity = 1 / (distance + 1)

distance是概念网中两个 synset 之间的最短路径边数。自己和自己的距离是 0,所以分数是 1.0;隔一层父节点,距离是 1,分数是 0.5;隔越远,分数越低。这个算法好懂,但有个明显缺陷:它把所有边的权重都当成 1。在“动物”和“哺乳动物”之间隔一条边,和“金毛犬”和“拉布拉多犬”之间隔一条边,按 path 算是一样的,但语义上的亲疏程度显然不一样。

lch_similarity就是为了修正这个问题的。它把概念树的总深度D放进公式:

lch = -log(distance / (2 * D))

如果你对 log 不熟,就记住它的效果:同样的距离,放在层级深的区域里,分数会更高;放在靠近根节点的稀疏区域,分数会降低。换句话说,lch 认为“叶子附近的概念更具体,具体概念之间隔一条边,比抽象概念之间隔一条边更有意义”。

wup_similarity(Wu-Palmer)是另一个思路,它找两个 synset 的最近公共祖先 LCS,然后比较 LCS 的深度和两个节点自身的深度:

wup = 2 * depth(LCS) / (depth(s1) + depth(s2))

拿家族关系打比方:两个表兄弟共享一个爷爷,爷爷这一代离他们越近,亲缘关系越明显;如果共享的是几百年前的祖先,那关系就远了。wup 计算的就是“公共祖先占两个节点深度的比例”。它天然落在 0 到 1 之间,解释性很强。我在通用场景里默认用它,原因就是它的数值口径最容易定阈值。

4.2 基于信息量:res、jcn、lin

路径类算法的短板是只看结构,不看真实语料里概念出现的频率。比如entity这种顶层概念,在几乎所有句子里都隐含出现,但它的信息量很低;而golden_retriever这种叶子概念,一出现就很具体,信息量很高。

信息量IC的定义是:

IC(c) = -log(p(c))

其中p(c)是概念 c 在语料里出现的概率,WordNet 计算时会把 c 的所有下位词出现次数都累加到 c 头上。概念越抽象,出现概率越高,IC 越低;概念越具体,IC 越高。wordnet_ic里那几个 dat 文件,就是预计算好的 IC 映射表。

三种基于信息量的算法公式分别是:

res = IC(LCS) lin = 2 * IC(LCS) / (IC(s1) + IC(s2)) jcn = 1 / (IC(s1) + IC(s2) - 2 * IC(LCS))

res只看公共祖先的信息量,不考虑两个词自身的信息量,所以分数是无界的原始 IC 值,不太适合直接定阈值。lin把它变成了“公共信息占两个词总信息量的比例”,范围接近 0 到 1,是我做阈值判断时会考虑的选项。jcn的思路不同,它衡量的是“差异度”,分母越大,差异越大,分数就越低;分母接近 0 时,分数会变成无穷大或非常大,代码里必须做防御。

在 NLTK 里调用:

from nltk.corpus import wordnet_ic semcor_ic = wordnet_ic.ic('ic-semcor.dat') dog = wn.synset('dog.n.01') cat = wn.synset('cat.n.01') print(dog.res_similarity(cat, semcor_ic)) print(dog.lin_similarity(cat, semcor_ic)) print(dog.jcn_similarity(cat, semcor_ic))

这里有个实战细节:ic-brown.dat基于布朗语料库,ic-semcor.dat基于 SemCor。SemCor 是带语义标注的,概念切分更干净,IC 值更可靠。我实际做实验时优先用ic-semcor.dat,速度影响不大,结果解释起来舒服很多。

4.3 到底怎么选

六种算法选哪种,没有绝对标准,但我有自己的一套选择逻辑。整理成一个表给你:

算法核心依据大致范围我常用的场景
path路径边数0 - 1快速原型、粗筛
lch路径 + 树深度0 到几名词、动词层级深且关心具体层级
wup公共祖先深度0 - 1通用首选,短文本去重
res公共祖先信息量0 到正数学术对比、研究实验
lin信息量归一化0 - 1需要 0-1 分数且语料丰富的任务
jcn信息量差异度0 到正数希望拉开词义之间差距时

一句话总结:如果只是做工程,没有特殊理由,我默认wup;如果在意“具体 vs 抽象”的层级差异,就试试lch;如果手上有领域语料,想自己统计 IC,那就用lin或jcn,因为它们的 IC 分量可以换成你自己的统计结果。

5. 踩坑实录:最容易翻车的五个场景

5.1 词性不同,结果直接 None

最经典的翻车现场是拿名词和动词比。狗是名词体系的节点,跑是动词体系的节点,两者的 is-a 树在顶层是分开的。path_similarity找不到连接路径,就会返回None。lch_similarity干脆直接抛WordNetError。

我有一次做句子对齐,把dog和run送去算分数,结果整批结果全是 0。后来排查才发现不是数据问题,是词性体系压根不互通。解决办法有两个:要么比较前用词性标注过滤,只比较同词性;要么对跨词性组合直接返回 0,别指望这棵树能跨体系。

5.2 一词多义,默认取第一个 synset 会翻车

wn.synsets('bank')会返回好几个 synset,默认排序并不是经常出错的——第一个往往是 WordNet 认为最常见的词义,但对你的句子来说可能完全不对。bank的第一词义可能是“河岸”相关,你拿它去跟money比,自然低得离谱。

我用过一个土办法:取最大相似度,也就是把两个词的所有 synset 组合都算一遍,取最高分。这在候选词不多时还能用,但严格说不是词义消歧,只是“蒙对”。真正的做法是用上下文去选词义,NLTK 自带一个简化版 Lesk 算法:

from nltk.wsd import lesk context = "I opened a bank account".split() best_sense = lesk(context, 'bank', pos='n') print(best_sense.name())

如果报缺stopwords数据,先nltk.download('stopwords')。Lesk 的原理不复杂:比较上下文中每个词和目标 synset 的 definition 之间的重叠词数,重叠多的词义胜出。它对短句效果还行,长句和图省事的时候我还是用“最大相似度”策略,但要心里清楚那是兜底。

5.3 词形没还原,相似度归零

running和run如果直接查wn.synsets,会被当成两个不同的词。running作为名词确实有 synset,但跟动词run的语义位置差远了。所以前面才专门写了smart_lemm,把所有词性都试一遍。

这里最容易踩的坑是只按名词还原。很多半成品代码把lemmatize('running')改成lemmatize('running', pos='v')之前,结果永远是错的。业务文本里动词、形容词形态特别多,光处理名词远远不够。我的smart_lemm里取最短候选,就是因为还原后的词元一般比原词短,比如better->good、went->go。

5.4 信息量计算出现 inf 和异常大数

用jcn_similarity时,如果两个 synset 完全相同,或者它们的公共祖先 IC 特别大,分母可能为 0,分数就会变成无穷大。还有的场景里,lin_similarity会因为概率估计的问题算出超过 1 的分数。

代码层面的防御很简单:把不满足math.isfinite(v)的分数全部过滤掉。这点前面函数里已经体现了。不要心存侥幸,批处理几千条词对时,任何异常值都会污染最终统计结果。

5.5 版本和语料不同,数值别直接比较

NLTK 不同版本、不同 WordNet 数据文件,算出来的相似度数值会有细微差异。网上教材里给你的某个dog和cat的 wup 数值,换成你的环境可能不一样。

我拿两个不同环境跑过同一批词对,wup基本一致,但lch和基于 IC 的算法差异明显,尤其是ic-brown.dat和ic-semcor.dat之间。所以你的代码里最好把 IC 文件来源写清楚,文档里注明是哪个版本、基于什么语料。否则过了三个月,连你自己都分不清这批分数是什么口径算出来的。

以下是一个简单速查表,方便排查:

现象可能原因处理方式
返回 None词性体系不互通过滤同 POS 或直接返回 0
结果都很低没有词形还原用WordNetLemmatizer并指定 pos
同义词分数不是 1比较的是不同 synset确认是否对齐到同一个 synset
jcn 出现 inf分母为 0过滤非有限值
数值和环境对不上数据版本/IC 语料不同固定数据版本并记录来源

6. 我把这套相似度用在了哪些场景

6.1 短文本语义去重

WordNet 相似度最常见的落地是短文本去重。两个购物评价、两个标题,字面完全不同,但意思接近,可以用“词对齐平均”的方式打一个分。

我写过类似这样的句子级函数:

def sentence_wup_sim(sent_a, sent_b): ta = [smart_lemm(w) for w in sent_a.split()] tb = [smart_lemm(w) for w in sent_b.split()] if not ta or not tb: return 0.0 total = 0.0 for wa in ta: best = 0.0 for wb in tb: best = max(best, word_sim(wa, wb, method='wup')) total += best return total / len(ta)

思路很朴素:对句子 A 的每个词,在句子 B 里找相似度最高的词,然后取平均。如果两个句子是同一个意思,大部分词都能找到高分对齐,平均值会很高;如果句子含义差异大,平均值就低。这个方法的优点是快、解释性强;缺点是没考虑语序,长句会失真,所以我只用于短文本。

阈值方面,经过若干轮测试,我发现wup均值在 0.85 以上时,短文本多半是改写关系;0.7 到 0.85 之间要看具体业务,可能是同主题但不同意图;0.7 以下基本可以认为不是同一句话。阈值没有普适值,一定得在自己的数据上抽一批样本人工标定。

6.2 搜索词扩展与词典构建

搜索场景里,用户可能只输入“汽车”,但数据库里存的是“轿车”“SUV”“跑车”。这时候不需要上向量模型,直接用 WordNet 就能做查询扩展:

car = wn.synset('car.n.01') hypos = car.hyponyms() lemma_names = [] for h in hypos[:10]: lemma_names.extend(h.lemma_names()) print(lemma_names)

除了下位词,还可以用高相似度词做同义替换。比如某词库要扩同义词,我会先把已有种子词和候选词全部算一遍wup,只保留分数大于 0.9 的候选。用这种方法做出来的同义词表干净、可控,每个词义都能一眼看出为什么入选,比黑盒 embedding 好解释得多。

6.3 与词向量互补

现在大家动不动就上 word2vec、BERT,但我不觉得 WordNet 相似度跟它们是替代关系。拿“狗”和“猫”举例,word2vec 可能因为语料里两者频繁共现,给出一个可以接受的相似度;但换成“厨师”和“厨房”,向量可能因为上下文相似也给高分,这在语义相似任务里就容易误导。WordNet 因为只看 is-a 层级,会明确告诉你厨师和厨房不属于同类,分数很低。

我现在的做法是:先用 WordNet 做规则层过滤,过滤掉明显不相关的组合,再用向量模型做精排。比如在搜索模块里,WordNet 负责拦截“跨词性”“跨大类”的荒谬匹配,向量模型负责应对那些 WordNet 覆盖不到的新词、人名、产品名。两者结合,既保留了解释性,又弥补了词典覆盖面不足的问题。

6.4 一点个人体会

我一开始也觉得 WordNet 又老又笨重,词表不如语料库大,新词完全没辙。但用了几轮之后,反而喜欢上它的确定性。wn.synset('dog.n.01').wup_similarity(wn.synset('cat.n.01'))每次跑都是一样的,不会因为随机种子、训练轮数产生抖动,也不会因为 GPU 版本不同而改变。做正式结论、写技术报告、给非技术同事解释结果时,这种稳定性比“模型效果提升两个点”更能让人安心。

我最后的建议是:不要在代码里临时堆函数。把smart_lemm、word_sim、sentence_wup_sim这些工具单独整理成一个模块,把 IC 文件、WordNet 版本、算法默认值都写进配置。你会发现,后续所有跟语义距离相关的活儿,都可以复用这一层基础设施。到那时,WordNet 相似度就不再是“一个函数”,而是你自己工具集里的底盘之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询