简介:这是一套面向计算机、人工智能、自动化等专业学生与从业者的Python毕业设计源码,基于事理图谱构建完整的事件推理系统,解决从非结构化文本中抽取事件、提炼关系并形成可推理图谱的核心问题,覆盖网络爬虫、事件提取、关系抽取、事件向量化、K-means++聚类以及图谱可视化等全流程。包内共34个文件,以Python脚本为核心,辅以XML配置、CSS/JS前端文件、CSV数据文件、Markdown说明等,压缩包整体约668KB。代码按功能模块组织:Crawler实现数据采集,NLP基于LTP完成事件与关系抽取,EventAbstract实现事件向量化与K-means++算法,IO负责数据库操作和事理图谱可视化,并附带测试文本与关系数据,便于快速验证运行。已有96人学习,项目答辩评分达98分,代码经调试确认可运行。读者可借鉴其模块划分与算法实现,掌握LTP、事理图谱构建、事件推理等关键技术,亦可在此基础上修改扩展,完成自己的课程设计或毕业设计。
1. 事理图谱事件推理系统的定位:毕业设计选题与工程量评估
事理图谱事件推理系统是 Python 毕业设计里出镜率很高的一类选题,它把「事件」当作节点,把事件之间的因果、时序关系当作有向边,构成一张事件图,然后在图上回答两类问题:某个事件发生后最可能引发什么,某个事件最可能由什么导致。相比情感分析、文本分类这些被写了很多的 NLP 题目,它的工程纵深更明显,能串起爬虫、自然语言处理、图算法和 Web 交付,答辩也容易讲出层次。不过它的坑同样明确:事件抽取做得糙,后续推理全是空中楼阁;推理算法选得不对,结果会被语料里的高频泛化词带偏。下面按我搭同类系统的习惯,把数据层、推理层、服务层和评测方式逐层拆开讲,适合正在定题的本科生,也适合用图加文本做小系统的后端工程师参考。
2. 事理图谱构建:事件抽取、关系识别与存储选型
事理图谱的数据层决定推理质量的上限。很多毕业设计把时间花在算法调参上,结果发现错的不是参数,是图谱本身:事件节点没有归一化,「股价下跌」和「股价出现下跌」是两个节点,永远算不出关联;关系权重是拍脑袋给的,推理结果自然没法复现。所以构建层要解决三件事:事件怎么表示、事件关系怎么来、图往哪里存。这一层做完,后续的推理只是在它上面做图搜索和传播。
2.1 事件触发词与事件表示的落地选择
在事理图谱里,事件的最小表示通常是「动词 + 核心论元」,比如「下雨」「城市内涝」「央行降准」。实操中先做触发词识别:对句子做分词和词性标注,把动词、动名词作为候选触发词,再用规则把动词前后的实词拼进事件串。依赖解析工具(LTP、HanLP)能拿到更准确的主谓宾结构,但这类工具包体积大、模型加载慢;如果语料是新闻标题这种短文本,用词性标注加窗口拼接就足够,而且规则完全可解释,答辩时能直接回答「为什么这样抽取」。
# event_extract.py import jieba.posseg as pseg STOP_EVENT = {"进行", "成为", "出现", "发生"} # 光杆动词辨义弱,直接过滤 def extract_events(sentence): """从单句抽候选事件,返回事件串列表""" words = list(pseg.cut(sentence)) events = [] for idx, (word, flag) in enumerate(words): if flag.startswith("v") and word not in STOP_EVENT and len(word) >= 2: # 动词向前取一个实词当主语,向后取一个实词当宾语 left = "" if idx > 0 and words[idx - 1].flag in ("n", "r"): left = words[idx - 1].word right = "" for w, f in words[idx + 1 : idx + 4]: if f in ("n", "ns", "nt", "vn"): right = w break event = "".join([left, word, right]) if len(event) >= 4: events.append(event) return events分词结果里flag以v开头的词就是动词候选,n/r/vn是名词、代词和动名词,分别作为事件的主语和宾语候选。words[idx+1:idx+4]限制只看动词后面三个词,避免宾语跨过整个介词短语。这个轻量抽取在新闻标题上的事件还原率能稳定在六到七成,毕业设计阶段真正需要的不是 SOTA,而是覆盖度可控、可解释。抽出的事件建议再走一层归一化:全角转半角、去掉「了」「着」等时态助词,否则「股价下跌了」和「股价下跌」会在图里变成两个节点。
2.2 事件关系抽取:规则与统计基线的组合
事件关系分因果和时序两类,因果管「为什么」,时序管「然后呢」。规则层维护一张触发词表:出现「导致、引发、造成」等连接词时,把连接词前后的候选事件连成有向边,关系类型记 causality,置信度按触发词的明确程度赋值,「导致」给 0.9,「相关」给 0.5。统计层做共现:在同一文档或相邻窗口内反复出现的事件对,即使没有连接词,也存在潜在关联,用条件概率和 PMI 打分,超过阈值再入图。
# relation_mining.py from collections import defaultdict from math import log class RelationMiner: def __init__(self, window=3, min_count=2): self.window = window # 共现窗口,按事件序列位置计算 self.min_count = min_count # 过滤低频噪声的最小共现次数 self.freq_a = defaultdict(int) self.freq_ab = defaultdict(int) self.n = 0 def feed(self, event_seq): """喂入一条事件序列,例如 ['央行降准', '银行放贷']""" self.n += 1 for i, a in enumerate(event_seq): self.freq_a[a] += 1 for b in event_seq[i + 1 : i + self.window]: if a != b: self.freq_ab[(a, b)] += 1 def edge_weight(self, a, b): """P(b|a) 加 1 平滑;共现不足 min_count 的边直接丢弃""" cnt_ab = self.freq_ab.get((a, b), 0) if cnt_ab < self.min_count: return None return cnt_ab / (self.freq_a.get(a, 0) + 1) def pmi(self, a, b): """点互信息,用于过滤高频但无意义的事件对""" p_ab = self.freq_ab.get((a, b), 0) / self.n p_a = self.freq_a.get(a, 0) / self.n p_b = self.freq_a.get(b, 0) / self.n if p_ab == 0 or p_a == 0 or p_b == 0: return 0.0 return log(p_ab / (p_a * p_b))window控制关联跨度,窗口越大召回越高但噪声越多,新闻语料我一般设 3;min_count设 2 或 3,低于这个数的事件对大多是偶然共现。PMI 在这里是兜底过滤:像「进行」「开展」这类高频泛化动词容易被共现绑定,PMI 很低,直接剔除。这里必须提醒一句:统计关系不等于因果关系,它只能表达「A 之后经常出现 B」,所以边的属性里要同时存relation_type和source,后续推理按类型拆分,论文分析也可以分别讨论两种关系的贡献。
2.3 图谱存储选型:NetworkX 还是 Neo4j
| 对比项 | NetworkX | Neo4j |
|---|---|---|
| 部署成本 | pip 安装即用,依赖少 | 需要独立服务和 Java 环境 |
| 数据规模 | 万级节点流畅,百万级吃力 | 千万级以上的专业场景 |
| 查询方式 | Python 遍历与算法函数 | Cypher 图查询语言 |
| 可视化 | matplotlib、pyecharts 导出 | Neo4j Browser 内置 |
| 代码可读性 | 高,答辩时好讲 | 中,变成两条技术线 |
对毕业设计的数据量——几千到几万事件节点——NetworkX 完全够,而且最短路径、PageRank、连通分量都是现成函数,源码就摊在面前,答辩被追问算法细节时可以直接对着代码讲。Neo4j 的优势在可视化展示和 Cypher 查询,如果选题更看重系统感,可以做「NetworkX 算结果、Neo4j 出展示」的混合方案,但要先算清楚环境配置的时间成本,先把python环境跑通再决定要不要加数据库。
# graph_build.py import networkx as nx G = nx.DiGraph() # A -> B 表示 A 发生后更可能发生 B def add_relation(a, b, rtype, weight, source): if G.has_edge(a, b): old = G[a][b]["weight"] G[a][b]["weight"] = max(old, weight) # 同一条边来自多个语料,取最高置信度 G[a][b]["support"] += 1 else: G.add_edge(a, b, relation=rtype, weight=round(weight, 4), source=source, support=1) def save_graph(path="data/event_graph.graphml"): nx.write_graphml(G, path) # GraphML 带属性落盘,论文附录可直接引用边属性是关键:relation区分因果和时序,weight是融合后的置信度,support是统计支持数,source记录语料文档 ID。这样推理阶段可以按类型过滤,出现「把因果序列当成时序序列」这类逻辑错误时能直接追溯数据。写盘用 GraphML 而不是 pickle,GraphML 是文本格式,可以再导入 Gephi 或 Neo4j,也方便做 git 版本对比。
3. 事件推理的核心算法:路径可达、影响传播与置信度计算
图谱建好后,事件推理其实是在一张有向加权图上回答三类查询:从 A 出发往哪走(前向结果推演)、从 B 往回看什么最可能造成它(后向原因溯源)、一堆候选后续事件里哪个概率更高(排序)。这三类查询可以统一抽象成「带方向、带权重、带深度约束的图上搜索与传播」。下面按我常用的落地顺序拆开,前向和后向是同一套代码,传播算法和置信度融合才是真正的区分点。
3.1 前向结果推演与后向原因溯源:深度受限的可达性检索
最直觉的做法是限定深度的广度优先搜索。深度就是「几跳」,一跳表示直接因果,两跳表示间接影响。搜索结果按「先看近、再看多」排序:路径短的优先,路径相同时看节点的出度,出度高说明它与其他事件耦合强,展示价值更大。
# reasoning.py import networkx as nx def reason_forward(G, event, max_depth=3, top_k=5): """前向推理:event 发生后,最可能接着出现的事件""" if event not in G: return [] lengths = nx.single_source_shortest_path_length(G, event, cutoff=max_depth) ranked = [] for node, dist in lengths.items(): if node != event and dist > 0: ranked.append((node, dist, G.out_degree(node))) ranked.sort(key=lambda x: (x[1], -x[2])) return [(node, dist) for node, dist, _ in ranked[:top_k]]后向溯源把single_source_shortest_path_length换成G.reverse(copy=False)再跑,代码只差一行。cutoff=max_depth直接限制推理链长度,超过三跳的链条在语义上基本不可解释,演示系统给五跳以上的结果只会自找麻烦。这里有个容易踩的坑:最短路径函数只返回距离,不区分「等长但边权不同」的两条路径,所以排序时要自己补第二关键字-G.out_degree(node),不然随机性太大,每次跑出来的 Top-K 顺序都不一样。
3.2 事件影响传播:带衰减系数的加权传播算法
可达性只能回答「有没有路径」,回答不了「哪条路径的影响更大」。这时候需要传播类算法:给一个或多个种子事件赋初始得分,得分沿出边按权重扩散,每走一跳乘一个衰减系数decay,迭代若干轮后收敛,得分最高的非种子节点就是影响最大的后续事件。这个思路和 PageRank 同源,但刻意做得更简单,论文里从公式到实现都能完整推导,不引入随机游走的收敛证明负担。
def propagate(G, seed_events, alpha=0.85, decay=0.6, iterations=12, top_k=10): """种子事件沿有向边传播影响力,返回得分 Top-K 的后续事件""" if not seed_events: return [] score = {n: 0.0 for n in G.nodes} for e in seed_events: if e in score: score[e] += 1.0 for _ in range(iterations): delta = {n: 0.0 for n in G.nodes} for u, v, d in G.edges(data=True): delta[v] += score.get(u, 0.0) * d.get("weight", 0.5) * decay for n in score: score[n] = alpha * delta[n] + (1 - alpha) * score.get(n, 0.0) ranked = sorted( (n for n in score if n not in seed_events), key=lambda n: score[n], reverse=True ) return [(n, round(score[n], 4)) for n in ranked[:top_k]]| 参数 | 推荐区间 | 语义 |
|---|---|---|
| alpha | 0.8 ~ 0.9 | 本轮得分中传播项的占比,越大扩散越充分 |
| decay | 0.4 ~ 0.7 | 每跳衰减,越小影响范围越近 |
| iterations | 10 ~ 20 | 小图上超过 20 轮基本不再变化 |
| top_k | 5 ~ 20 | 演示建议 5,方便逐条人工核对 |
传播算法有三个隐藏问题。第一,图里有环时(A 导致 B,B 又导致 A),迭代会在环内反复累加,靠decay < 1压住,所以衰减系数不能设成 1,这是最常见的一个错误。第二,迭代结束后的得分绝对值没有意义,只能看相对顺序,所以返回值里不需要置信度区间之类的东西。第三,泛化高频节点天然得分高,语料里「进行」「发生」这类事件如果没在建图时剔除,Top-K 会被它们霸榜,处理办法见 2.2 的 STOP_EVENT 机制。
注意:衰减系数不要设为 1,含环的子图会让得分在环内反复叠加,排序结果完全失真。
3.3 多候选路径的置信度融合
同一对事件之间往往存在多条推理链,比如「暴雨 → 内涝 → 交通瘫痪」和「暴雨 → 河道溢流 → 内涝 → 交通瘫痪」。每条链都是一条证据,先算单链置信度再合并。单链置信度取链上边权的乘积并乘衰减,即chain_score = weight(A,B) × weight(B,C) × decay^(len-1),当边权本身就是 P(B|A) 时,这等价于马尔可夫链的路径概率,数学上说得通。
| 融合策略 | 公式 | 适用场景 |
|---|---|---|
| 取最大值 | max(chain_i) | 证据之间高度相关,避免重复计算 |
| 求和后归一 | Σchain_i / Σall | 适合前端展示各候选事件的占比 |
| 概率加性或 noisy-or | 1 - ∏(1 - chain_i) | 把每条链当作独立弱证据,多个弱链共同支撑会变强 |
我倾向 noisy-or:它不会让同一条证据链重复叠加超过 1,又有「多路径联合支撑」的直觉,论文里好解释。实现上只需要在路径枚举时把每条链的概率算出来,再按终点事件分组聚合,代码量很小。需要明确的是,noisy-or 假设各链独立,真实语料并不满足,所以最终得分只用于排序,不能声称是任何形式的绝对概率,写论文时这个边界一定要讲清楚。
4. 事件推理系统的工程实现:数据流水线、API 与交互演示
算法能跑通只是第一步,毕业设计要交付的是一个能演示、能截图、能答辩的系统。这里按我习惯的分层讲:一条从原始语料到图谱文件的构建流水线,一个基于 FastAPI 的查询服务,一个用 Streamlit 写的交互面板,三者之间通过 GraphML 文件解耦。开始前先确认两件环境事项:python版本用 3.10 或以上,在 vscode 里把 python 解释器指到项目虚拟环境,避免networkx、fastapi、streamlit装进全局环境互相污染依赖,大量「昨天能跑今天报错」的问题都出在这个环节。
4.1 构建流水线:从语料到图谱的串行任务
流水线分四段:爬取清洗、事件抽取、关系挖掘、写图落盘。爬虫用requests加BeautifulSoup抓公开新闻站点的标题列表页,只做静态页面请求就能控制数据来源的合规与稳定。事件抽取和关系挖掘复用第 2 章的模块,最后把规则边和统计边合并进 NetworkX 图。四段任务串在一个进程里,任何一段出错都能根据命令行参数单独重跑。
# pipeline.py import argparse import networkx as nx from crawler import fetch_news_titles from event_extract import extract_events from relation_mining import RelationMiner from graph_build import add_relation, save_graph def run(input_urls, out_path): miner = RelationMiner(window=3, min_count=2) G = nx.DiGraph() for url in input_urls: for title in fetch_news_titles(url): # 1. 抓取与清洗 seq = extract_events(title) # 2. 事件抽取 miner.feed(seq) # 3. 共现统计 for (a, b), cnt in miner.freq_ab.items(): w = miner.edge_weight(a, b) if w and miner.pmi(a, b) > 0.5: # 阈值过滤,去泛化噪声 add_relation(a, b, "temporal", w, f"corpus#{cnt}") save_graph(out_path) if __name__ == "__main__": ap = argparse.ArgumentParser(description="事理图谱构建流水线") ap.add_argument("--urls", nargs="+", required=True, help="新闻列表页地址") ap.add_argument("--out", default="data/event_graph.graphml", help="图谱输出路径") args = ap.parse_args() run(args.urls, args.out)--urls一次可传多个数据源,--out指定输出文件。把清洗逻辑放在crawler模块而不是事件抽取里,是刻意做的边界划分:爬虫只负责拿干净文本,NLP 模块只负责从干净文本出事件。答辩被问模块划分时,这个理由一句话就能讲清。跑完记得检查G.number_of_nodes()和G.number_of_edges()的数量级,节点数千级、边数万级是正常状态,如果边数比节点数少,说明共现统计的窗口或阈值有问题。
4.2 推理查询 API:FastAPI 参数设计
推理服务要暴露两类接口:前向推理和后向溯源。参数设计上,event允许输入原句,服务端先做归一化映射;depth限制图搜索层数;top_k控制返回条数。比参数更重要的是「查不到事件」时的降级行为:不能直接返回空列表,要返回相似事件候选,否则演示环节输入一个语料里没有的词,当场冷场。
# api.py from fastapi import FastAPI, Query from reasoning import reason_forward from matcher import normalize_event, suggest_events app = FastAPI(title="事理图谱事件推理API") @app.get("/reason/forward") def forward( event: str = Query(..., min_length=2, description="起点事件或包含事件的句子"), depth: int = Query(3, ge=1, le=5, description="推理深度,超过5跳不可解释"), top_k: int = Query(5, ge=1, le=20, description="返回候选事件数"), ): node = normalize_event(event) # 精确匹配 -> 子串匹配 -> 词向量相似度 if node is None: return {"error": "event_not_found", "suggestions": suggest_events(event)} results = reason_forward(nx_graph, node, max_depth=depth, top_k=top_k) return {"event": node, "depth": depth, "candidates": [ {"event": e, "hops": d} for e, d in results ]}| 参数 | 类型 | 约束 | 说明 |
|---|---|---|---|
| event | str | 必填,最小长度 2 | 事件或包含事件的句子,服务端做三级归一化 |
| depth | int | 1 ~ 5 | 搜索层数,超过 5 跳语义不可解释 |
| top_k | int | 1 ~ 20 | 返回候选数,演示场景建议 5 |
normalize_event的实现是「精确匹配 → 子串匹配 → 词向量相似度匹配」三级降级:先看图里有没有这个节点,没有就找包含该词的节点,再没有就用 gensim 词向量取 top-3 相似事件让用户确认。把这段逻辑单独抽成matcher.py很值得,因为前端输入永远比图里的节点不规范,这块代码的质量直接决定演示顺不顺畅。Query(ge=1, le=5)这类校验是 FastAPI 自带的能力,避免前端传一个 100 进来把整图遍历一遍。
4.3 演示面板:Streamlit 交互界面
Streamlit 是毕业论文演示性价比最高的方案:表格、图表、滑动条都是声明式组件,不用写 JavaScript。面板按答辩节奏设计:第一屏放示例事件按钮(取图里出度最高的节点),第二屏放深度和返回条数滑动条,第三屏同时渲染推理结果的表格和子图。
# app.py import pandas as pd import matplotlib.pyplot as plt import networkx as nx import streamlit as st st.set_page_config(page_title="事理图谱事件推理", layout="wide") G = nx.read_graphml("data/event_graph.graphml") event = st.selectbox("选择起点事件", sorted(top_events(G, n=30))) depth = st.slider("推理深度", 1, 5, 3) top_k = st.slider("返回数量", 1, 10, 5) sub = extract_subgraph(G, event, depth) fig, ax = plt.subplots(figsize=(10, 6)) nx.draw(sub, ax=ax, with_labels=True, node_color="#f7c95c", font_size=10, edge_color="#9aa5b1") st.pyplot(fig) st.dataframe(pd.DataFrame(reason_forward(G, event, depth, top_k), columns=["候选事件", "跳数"]))extract_subgraph负责把从起点在depth层内可达的节点和边截出来,画图只用子图,几千个节点画在一张图里什么都看不清。演示时有三个细节容易加分:一是演示前先点两三个高频示例事件跑通缓存;二是把起点的node_color和普通节点区分开,观感上立刻知道推理从哪里开始;三是把edge_color按关系类型分色,因果用暖色、时序用冷色,截图放进论文可以直接当结果图用。
5. 推理效果验证:评测集构造与 hit@k 指标
推理系统最容易被答辩老师追问的问题就是「你凭什么说结果是对的」。回答它不需要复杂指标,但需要一套可见的验证流程:构造评测集、跑量化指标、留证据截图。这个环节在大多数毕业设计里是缺失的,把它做完整本身就是亮点。
5.1 评测集与三层评测指标
从语料里挑 30 到 50 个有代表性的起点事件,每个事件人工标注 3 到 5 个「应当被推出来」的后续事件作为正例,再额外标注若干明确「不应出现」的高频泛化事件作为反例。指标直接用信息检索那套:
| 指标 | 计算方式 | 用途 |
|---|---|---|
| precision@k | 前 k 条中正例占比 | 看结果里混入多少噪声 |
| hit@k | 前 k 条是否包含至少一个正例 | 演示场景更关心「有没有命中」 |
| MRR | 第一个命中位置的倒数均值 | 对排序质量敏感,能拉开参数差距 |
# evaluate.py def hit_at_k(predicted, gold, k=3): pred = [e for e, _ in predicted[:k]] return 1.0 if any(g in pred for g in gold) else 0.0 def mrr(predicted, gold): for idx, (e, _) in enumerate(predicted, start=1): if e in gold: return 1.0 / idx return 0.05.2 用评测集反推参数与留证技巧
量化评测还有一个额外价值:把第 3 章的参数从拍脑袋变成扫参。固定同一组测试事件,对depth分别取 2、3、4,对decay取 0.4、0.6、0.8,对alpha取 0.8、0.85、0.9,跑一张 hit@3 的二维表,取最好组合写进论文。这个表格在答辩时的说服力远超「经过反复实验调优」这句话。反例要单独跑一遍,验证 Top-K 里不出现「进行」「发生」这类泛化事件,这一步能直接证明建图阶段的 STOP_EVENT 过滤是有效的。
留证据的具体做法:在evaluate.py里为每个查询保存推理路径 JSON,字段包含event、gold、predicted、hit@k和路径上的边来源source。答辩被问「这个结果怎么来的」时,把路径连同原始语料文档 ID 一起展示,比任何解释都扎实。这些 JSON 文件最后整理进论文附录,是工作量最直观的呈现方式。跑完评测如果 hit@3 低于 0.5,先别调算法,回头查图构建层的归一化和关系阈值,推理效果问题大多出在数据层。
本文还有配套的精品资源,点击获取