ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

汉语词义消歧大作业:Lesk融合知网义原的完整实现

2026/10/1 15:56:15 拓冰建站 浏览量
汉语词义消歧大作业:Lesk融合知网义原的完整实现 简介面向计算机专业课程设计与期末大作业场景此项目经导师认可、获评98分是基于Python构建的汉语词义自动消歧系统适合NLP初学者、毕业设计学生及需要项目实战练习的开发者参考。压缩包共214个文件约14.75MB以192个txt语料数据为主体辅以Python主程序、UI界面文件、XML配置、项目配置、实验报告PDF、演示视频及示例图片覆盖数据预处理、模型构建、结果展示与文档编写全流程。已有518人学习下载。通过源码可快速理解词义消歧的实现思路与关键算法对照报告掌握实验设计、参数调优和结果分析结合录屏与界面文件能直观复现系统运行效果节省从零搭建与排错的时间。资源内包含多义词语料库、运行工程与界面设计目录层级分明便于按模块拆解学习是课程设计或项目练手的实用参考。1. 汉语词义消歧这个大作业难在哪个环节汉语里越是常用词词义越不稳定。“打篮球”是运动“打酱油”是购物“打毛衣”是编织同一个“打”在不同搭配里对应完全不同的词典义项。自然语言处理大作业里“汉语词义自动消歧系统”要解决的问题就是在给定句子中自动判定目标多义词该用哪个义项。听起来像查词典真正做起来却要处理分词噪声、知识库缺失和评估方法三个坎。适合选这个题的人有两类想绕开深度学习黑匣子、把传统方法做透的同学以及需要一份可解释代码和报告来应对中期答辩的同学。下面我把方案选型到源码实现的完整落地路径过一遍代码骨架可以直接拿过去改。2. 方案选型为什么是 Lesk 知网义原而不是 BERT先别急着写代码。消歧系统的核心瓶颈不在编程而在你拿什么知识去区分义项。同一个词在不同句子里语义不同系统总得有一个来源告诉它不同在哪里这个来源叫知识源。选型本质上就是选知识源。常见做法有三条路线基于词典规则的 Lesk、基于统计的词向量或 PMI、基于深度学习的预训练模型。对大作业来说后面两条各有各的劝退点词向量路线要额外找语料训练或下载模型答辩时解释不清“向量为什么接近”BERT 路线代码虽然短但推理过程是个黑匣子评委追问“凭什么选这个义项”时容易卡壳。所以我一般建议走词典规则 语义知识库融合这也是本标题这种源码包里最常见、也最容易被接受的做法。2.1 用 Lesk 做基线是性价比最高的第一版Lesk 算法是 1986 年提出的经典方法核心思想一句话把目标词上下文中的词与每个候选义项的释义、例句做词形重叠重叠数量最多的义项胜出。比如“他喜欢打鼓”里上下文有“鼓”候选义项“击打”的例句是“打鼓”词形一撞就得一分系统就此把“打”判给击打义项。为什么先做 Lesk代码量只有十来行判决依据完全透明每个输出结果都能回指到句子里的词形证据。大作业答辩时评委几乎必问“你为什么选这个结果”Lesk 可以现场指着特征词解释这是深度学习路线做不到的。在有现成词典义项表的前提下Lesk 是帮你把数据管线跑通的最快第一版。Lesk 的缺点也明显它不认识同义词。上下文里出现“敲”而释义里写的是“击”词形完全不重叠系统就断了线索。更麻烦的是“击鼓传花”这种场景目标词周围全是和“击打”没有词面关系的词汇Lesk 直接失灵。所以它只能当基线不能当终版。2.2 知网 HowNet 的义原让“敲”和“击”产生关联HowNet知网不是一部面向翻译的电子词典而是一个以“义原”为最小语义单位的常识知识库。义原可以理解为描述概念的最小标签比如“敲击”“承受”“运动”。一个词在某个义项下的 DEF定义式是若干个义原的组合同一个多义词在 HowNet 里会列出多个义项每个义项的 DEF 不同——这正是消歧需要的知识。HowNet 消歧的道理很朴素上下文里的搭配词会贡献各自的义原目标词的每个候选义项也有义原哪个候选义项和上下文词义原的树距离最近就选哪个。例如“鼓”的义原里包含“乐器/敲击物”它和“击打”义项里的“敲击”义原在树上的距离很近于是即使句子中没有“敲”这个字出现“击打”义项的得分也会被拉高。它补的正是 Lesk 缺失的同义泛化能力。课程资料里常能拿到两个文件一个是词典文件每行是词条、词性、DEF另一个是义原层级树文件记录义原之间的上下位关系。层级树用于计算两个义原在树结构上的距离是 HowNet 相似度的核心输入。如果你的课程包里没有完整树文件也可以退化成“义原完全相同才算相似”但消歧能力会明显下降这点先心里有数。2.3 融合判决词面证据和语义证据一起投票两种证据各有偏向Lesk 在词面强相关时非常准HowNet 在词面不同但语义相关时兜底。把两个分数做加权融合是常见做法我一般用这个判决公式最终得分 lam × Lesk 得分 (1 - lam) × HowNet 相似度lam 默认取 0.4也就是语义证据占更大比重为什么给 Lesk 更小的权重词形重叠一旦成立证据很强但它经常为 0如果权重太高整条句子的消歧结果就被少数几次巧合的字符串碰撞绑架了。语义证据大部分情况下都能算出来更适合做稳定底盘。lam 是要调的参数后面评估部分我会讲怎么在开发集上扫值。三种路线放在一起对比更直观路线知识依赖实现量解释性大作业主要风险词典规则 Lesk一本带释义和例句的词典约 100 行高同义替换时失分知网义原融合HowNet 词典文件 义原树约 300 行中高KnowNet 文件格式解析要小心词向量 / 预训练模型外部模型或语料代码少但环境重低黑匣子难答辩环境配置费时间环境上这个方案不需要 GPU不需要装 PyTorch。python 3.9 到 3.11 都够用用 vscode 打开工程目录建一个 venv装一个 jieba 就完事。下面进入实现。3. 从零实现消歧核心词典解析、上下文抽取与判决函数代码按最小可用系统来组织核心文件只有一个wsd_core.py评估和演示各占一个文件。这里贴出的都是能直接跑通的骨架参数和数据结构都做了简化方便你改成自己源码包里的格式。3.1 工程结构与运行环境建议按下面的目录组织数据文件放在data/下代码和测试数据分离wsd/ ├── data/ │ ├── hownet.dat # HowNet 词典文件词条、词性、DEF │ ├── sememe_tree.txt # 义原层级树父TAB子 │ ├── senses.json # 多义词候选义项表 │ └── test.tsv # 测试集句子TAB目标词TAB义项ID ├── wsd_core.py # 词典解析 相似度 判决 ├── evaluate.py # 读取测试集并计算指标 └── demo.py # 单句演示入口环境准备两步python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install jieba全部第三方依赖只有 jieba。不要急着引入 sklearn 或 torch这个任务用不上还会拖慢答辩前在教室电脑上现场跑 demo 的启动速度。3.2 解析 HowNet 的 dat 文件从一行文本到义原列表HowNet 的词典文件不同版本格式有些出入最常见的是空格或 tab 分隔的三列词条、词性、DEF。DEF 里义原放在大括号中要先把碍事的修饰符号剥掉# wsd_core.py 片段一HowNet 词典解析 import re from pathlib import Path def load_hownet(dat_path): 解析 HowNet 词典文件返回 {词: [{pos, sememes}, ...]} lexicons {} for line in Path(dat_path).read_text(encodingutf-8, errorsignore).splitlines(): line line.strip() if not line or line.startswith(#): continue parts line.split() if len(parts) 3: continue word, pos, raw_def parts[0], parts[1], .join(parts[2:]) # 不同版本 DEF 里可能带 % ~ 这类关系修饰符直接丢弃 m re.search(r\{(.*?)\}, raw_def) if not m: continue sememes [s.strip().strip(%~) for s in m.group(1).split(,) if s.strip()] lexicons.setdefault(word, []).append({pos: pos, sememes: sememes}) return lexicons这个函数按行读取跳过注释行用正则把大括号里的内容抽出来再按逗号切开。.strip(%~)是去掉义原前后的修饰符。读取时加errorsignore是为了防止个别行编码问题拖垮整个文件。解析结果中同一个词可能对应多个词性条目比如“打”既可以当动词也可以当介词这正好为消歧提供了多个候选义项。如果想减少噪声可以在这里加一行过滤只保留动词或名词词性。3.3 义原树相似度两个义原在树上有多近义原层级树文件里每行是一个父子关系这里约定格式为“父义原 子义原”。加载后建立子到父的映射然后沿父子链找公共祖先用距离换相似度# wsd_core.py 片段二义原树相似度 def load_sememe_tree(tree_path): 读取义原层级返回 {子义原: 父义原} parent_of {} for line in Path(tree_path).read_text(encodingutf-8, errorsignore).splitlines(): line line.strip() if not line or line.startswith(#): continue parts line.split() if len(parts) 2: parent_of[parts[1]] parts[0] return parent_of def _path_to_root(sememe, parent_of): path [] while sememe in parent_of: path.append(sememe) sememe parent_of[sememe] path.append(sememe) # 根节点 return path def sememe_sim(s1, s2, parent_of, alpha1.6): 两个义原在树上的相似度alpha 是平滑系数HowNet 论文里常见取 1.6 if s1 s2: return 1.0 p1 _path_to_root(s1, parent_of)[::-1] # 反转成从根到叶子 p2 _path_to_root(s2, parent_of)[::-1] common 0 for a, b in zip(p1, p2): if a b: common 1 else: break if common 0: return 0.0 dist (len(p1) - common) (len(p2) - common) 1 return alpha / (dist alpha)相似度公式用的是alpha / (距离 alpha)这个经典形式两个义原在树上的距离越远分母越大相似度越小。alpha 控制曲线的陡峭程度取 1.6 时距离为 2 的义原对相似度仍有 0.44是一个比较温和的衰减。如果你的树文件是从发布包里解出来的注意有些版本父子关系是反的跑之前先用一句话验证一个已知关系比如确认“敲击”能沿着映射链走到根节点。3.4 上下文窗口与特征词筛选窗口太大噪声就来了取目标词左右各 5 个词作为上下文窗口。重点在于停用词表这个任务不能用通用中文停用词表那些表把“不”“没”这种带语义的词也删了后面会引发大问题。这里只过滤纯虚词# wsd_core.py 片段三上下文抽取 import jieba STOP_WORDS {的, 了, 和, 是, 在, 就, 都, 而, 及, 与} def extract_context(sentence, target, window5): 返回上下文特征词列表只取目标词两侧 window 个词 tokens list(jieba.cut(sentence)) if target not in tokens: return [], tokens idx tokens.index(target) context [] for t in tokens[max(0, idx - window): idx] tokens[idx 1: idx 1 window]: if t not in STOP_WORDS and t ! target and t.strip(): context.append(t) return context, tokens这里有两个细节。第一tokens.index(target)只处理目标词第一次出现的位置如果一句话里同一个词出现多次默认只消歧第一次大作业足够用。第二窗口里混进标点符号也没关系标点不会出现在词典里对相似度贡献为零。如果一句话分词后目标词被jieba切碎了会在后面避坑章里专门处理。3.5 融合判决函数给每个候选义项打分判决函数是系统的核心输入一句话和一个目标词输出每个候选义项的分数和最终判决。候选义项来自senses.json每个义项包含 id、名称、释义、例词和人工指定的核心义原。上下文词的义原则是从 HowNet 词典里查出来的# wsd_core.py 片段四融合判决 def disambiguate(sentence, target, senses, hownet, parent_of, window5, lam0.4): context, _ extract_context(sentence, target, window) scores [] for sense in senses: # 1) Lesk 得分上下文词与释义、例词的字面重叠 gloss_words set(jieba.cut(sense[gloss] sense[example])) lesk_score sum(1 for c in context if c in gloss_words) / len(context) # 2) HowNet 得分上下文词义原与候选义项核心义原的树相似度 sense_sememes set(sense[key_sememes]) sim_sum 0.0 for c in context: for entry in hownet.get(c, []): for sem in entry[sememes]: for ss in sense_sememes: sim_sum sememe_sim(sem, ss, parent_of) hn_score sim_sum / (len(context) * len(sense_sememes) 1e-6) blended lam * lesk_score (1 - lam) * hn_score scores.append({sense: sense, lesk: round(lesk_score, 4), hownet: round(hn_score, 4), blended: round(blended, 4)}) best max(scores, keylambda x: x[blended]) return {best_sense: best[sense], scores: scores, context: context}两个得分都做了归一化避免窗口越大分数越虚高。Lesk 得分是重叠词数除以上下文长度相当于重叠比例HowNet 得分是所有义原对相似度总和除以词数和义原数的乘积相当于平均相似度。注意代码里用1e-6做了防御防止上下文为空时除零。如果context为空说明目标词周围全是虚词这时候任何方法都救不了系统会退回第一个候选义项这个兜底行为要在报告里写明。判决函数的参数汇总参数默认值作用调节建议window5上下文窗口半径在开发集上扫 2 到 8lam0.4Lesk 权重在 0.3 到 0.6 之间尝试alpha1.6义原树距离平滑系数1.2 到 2.0 影响不大4. 测试集与评估200 条标注句怎么建准确率怎么算才有说服力大作业翻车重灾区往往不在代码而在评估。很多人把“demo 跑出几个正确例子”当成“系统有效”这是自嗨。评估部分做扎实了报告的可信度能上一个台阶。4.1 自建测试集200 条标注句的构建标准不建议自己拍脑袋编例句那样会有意无意避开难句。常见做法是从新闻语料、百科条目、对话语料里抽取包含目标词的句子再人工标注义项。数量不用多10 个多义词、每词 20 句凑 200 句足够关键是抽样要随机不要只挑一眼能分清的句子。标注建议做成表格记录句子、目标词、gold 义项 ID 和标注人句子目标词gold ID标注人他在练习打鼓节奏很稳。打打-1xxx我去楼下打一斤酱油。打打-2xxx这件毛衣是妈妈打的。打打-3xxx如果条件允许找同学双人标注不一致的地方讨论后定稿如果只有自己一个人就在报告里写明“单人标注并对模糊例句记录决策依据”。诚实交代标注过程比假装严谨更经得起追问。4.2 评估脚本不要只看准确率多义词各个义项的样本数量天然不均衡有的义项出现 15 次有的只出现 3 次。只看 Accuracy 会出现一种假象系统把所有句子判给高频义项准确率依然不低。所以要同时算宏平均 F1也就是每个义项各自算 P 和 R再取平均# evaluate.py from collections import defaultdict from wsd_core import load_hownet, load_sememe_tree, disambiguate def load_test(test_path): cases [] with open(test_path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue sent, word, gold line.split(\t) cases.append((sent, word, gold)) return cases def evaluate(cases, senses_map, hownet, parent_of): stats defaultdict(lambda: {tp: 0, fp: 0, fn: 0}) for sent, word, gold in cases: result disambiguate(sent, word, senses_map[word], hownet, parent_of) pred result[best_sense][id] if pred gold: stats[gold][tp] 1 else: stats[gold][fn] 1 stats[pred][fp] 1 f1_list [] for label, st in stats.items(): precision st[tp] / (st[tp] st[fp]) if st[tp] st[fp] else 0.0 recall st[tp] / (st[tp] st[fn]) if st[tp] st[fn] else 0.0 f1 2 * precision * recall / (precision recall) if precision recall else 0.0 f1_list.append(f1) accuracy sum(1 for sent, word, gold in cases if disambiguate(sent, word, senses_map[word], hownet, parent_of)[best_sense][id] gold) / len(cases) macro_f1 sum(f1_list) / len(f1_list) return {accuracy: accuracy, macro_f1: macro_f1}注意这段代码每次评估都会重新跑一遍全部句子200 句体量小没关系但报告里要写明测试耗时不到一秒。写评估脚本最容易犯的错是混淆矩阵统计方向tp是在pred gold时给 gold 那个类加 1fn给 gold 类加 1fp给 pred 类加 1这样每个样本正好让一个类多一个命中、另一个类多一个误报逻辑上是闭合的。4.3 消融实验三行配置就够写报告答辩时最有说服力的一张表是消融实验。用同一个测试集跑三个配置配置预期表现说明纯 Lesklam1.0词面重叠精确但召回低同义替换场景大量失分纯 HowNetlam0.0能处理同义替换词典覆盖率不足时瞎投票融合lam0.4综合最好两个证据互补经验上融合比两个单独方法在同等条件下通常能提升 5 到 12 个百分点但这只是经验范围你的数据上可能不一样。如果融合反而更差直接去避坑章查第 3 和第 5 条大概率是窗口或数据泄漏问题。5. 词义消歧大作业避坑5 个翻车现场与修复步骤这一章按翻车频率排序。大多数问题不是算法原理错了而是数据装载和评估环节出了偏差。源码包里看到的“高准确率”很可能是下面某个坑被踩扁之后得到的幻觉。5.1 分词把搭配词切开系统被单字带偏现象测试“他每天打太极拳”消歧结果判成“击打”。打开分词列表一看打/太极/拳“拳”作为近邻词出现在窗口里“拳”和“击打”义项的释义强相关把分数带偏了。原因jieba 默认词典里没有“太极拳”这个多字词它被切成了“太极”和“拳”两个 token。解决给 jieba 加载自定义词典把领域词或常搭配的词组手动固定# data/user_dict.txt 太极拳 5 n代码里加一行jieba.load_userdict(data/user_dict.txt)。词频给 5 以上太低会不生效。做完之后重新看分词结果“太极拳”成了一个整体 token周围语境干净了不少。5.2 义项编号对不上评估脚本全盘错位现象单句 demo 怎么跑都对一上测试集准确率只有 30%。原因senses.json里人工定义的义项 ID 和test.tsv里 gold 列使用的 ID 不一致。这种错位很隐蔽因为代码不会报错只是永远判不中。解决写评估脚本之前先加一个 ID 集合校验。别等跑完看结果那是拿时间买教训sense_ids {s[id] for s in senses} gold_ids {gold for _, _, gold in cases} missing gold_ids - sense_ids assert not missing, fgold 中存在未定义的义项 ID: {missing}这就是大作业的后悔药。每次改完数据文件先跑一次断言能省掉半天排查时间。5.3 窗口参数被当成玄学实际是特征稀释现象把 window 从 3 调到 10准确率像过山车忽高忽低怎么解释都解释不通。原因窗口扩大后远离目标词的词汇带进来大量无关义原。当前实现里所有特征词权重相同远词和近词一视同仁噪声被等权累加。解决这不是玄学两种做法都行。最省事的是在开发集上扫窗口大小best_w max(range(2, 9), keylambda w: run_eval(windoww))报告里写一句“开发集上窗口 5 最好于是固定窗口 5”比“我选了 5”严谨得多。更精细的做法是按距离降权距离 1 的词权重 1距离 2 到 3 的词权重 0.5再远的按距离递减。这样窗口从 5 加到 8准确率也不会剧烈波动。5.4 停用词把“不”和“没”过滤掉否定句全判反现象测试“他不爱打人”时系统把“打”判给“买”义项。看半天找不到原因最后发现“不”被停用词表删了系统看到的上下文是“他爱人”语义全反了。原因网上找的通用中文停用词表是面向文本分类的里面大量包含“不”“没”“别”这类否定词。词义消歧里否定词与目标词的搭配恰恰是重要语义线索“不打”和“打”的语境差别很大。解决停用词表只保留纯虚词也就是我在 3.4 里写的{的, 了, 和, 是, 在, 就, 都, 而, 及, 与}这类没有任何实在语义的功能词。不要直接套开源停用词表那玩意在这个任务里的副作用远大于帮助。5.5 数据泄漏造成的高分幻觉现象测试集准确率 90%拿去对新句子一跑立刻露馅。原因调 window 和 lam 参数时直接用全部测试集参数被“偷看”了测试集更隐蔽的是统计词频或构建特征词典时把测试句也算了进去评判标准被污染。解决从 200 句里先留出 20 句作为开发集参数在开发集上调测试集只在最终评估时跑一次。知识库不受此限制HowNet 词典和义原树属于外部资源随便用。报告里写“开发集调参、测试集只跑一次”这一句话就能体现评估素养。排查顺序也有讲究先跑 ID 断言再查分词列表然后跑单句 demo 看中间分数接着扫窗口参数最后才看测试集总分。按这个顺序走95% 的问题都能定位到具体环节。6. 收尾技巧现场 demo 与错误分析表让报告更像工程代码和评估都跑通之后还有两件事能让报告从“课程作业”变成“像样的系统实现”。6.1 答辩现场 demo一句话输出判决依据答辩时最怕评委说“你现场跑一条”。demo 入口不能只是打印最终结果要把每个候选义项的分数都亮出来# demo.py import sys from wsd_core import load_hownet, load_sememe_tree, disambiguate def main(): sentence, target sys.argv[1], sys.argv[2] result disambiguate(sentence, target, senses, hownet, parent_of) print(f目标词{target}) for s in result[scores]: print(f {s[sense][id]}: lesk{s[lesk]} hownet{s[hownet]} score{s[blended]}) print(判决, result[best_sense][name], result[best_sense][gloss])分数一亮出来评委能直观看到系统是根据哪些证据做的决定。每个候选义项的 Lesk 和 HowNet 分数分离显示也让系统不再是一个黑匣子而是可以被审视的决策过程。顺带把输出重定向到文件写报告时可以直接引用真实案例。6.2 报告里最值钱的一张表错误分析与其花一整页写“系统取得了较高准确率”不如做一张错误分析表。从测试集里挑 8 到 10 个判错的样本人工归类错误原因目标词goldpred错误类型原因分析打打-2 购买打-1 击打近义混淆上下文“水”未映射到“购买”义原热热-3 热门热-1 温度高知识库缺失senses.json 缺该义项开开-5 驾驶开-3 打开窗口不足句末“车”超出窗口范围走走-4 离开走-2 行走分词错误“走红毯”被拆开在报告里统计一下错误类型占比知识库缺失占多少、近义混淆占多少、分词问题占多少。这个统计能直接指出系统下一步的改进方向比空谈“未来可以引入更多数据”扎实得多。我带课程设计这几年最常看到的翻车不是代码跑不动而是报告里的数字在自己电脑上复现不出来。把 demo、消融表、错误分析表做齐哪怕准确率一般答辩老师也能看出你是系统性地做完了这件事这就是大作业最划算的投入。希望帮到你。本文还有配套的精品资源点击获取