ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文复合事件抽取实战:从句子到事理图谱的Python流水线

中文复合事件抽取实战:从句子到事理图谱的Python流水线

简介:面向自然语言处理学习者与开发者的一份中文复合事件抽取实战项目,聚焦条件、因果、顺承、反转等事件类型的识别与抽取,并进一步构建事理图谱,适合具备一定机器学习与Python基础、希望深入事件抽取与语义推理方向的中高级读者。压缩包共11个文件,约553KB,以xml配置文件、png示意图、md说明文档与py核心脚本为主,xml与iml用于工程配置,png辅助理解事件关系,py脚本承担事件抽取逻辑,md提供项目说明。目前已有1533人学习下载。项目围绕数据预处理、模型训练、事件检测与图谱构建展开,涉及分词、句法分析及SVM、CRF、BERT等方法的选型思路,读者可据此理解从文本清洗到事件类型预测、再到图数据库存储展示的完整链路,并迁移至新闻摘要、舆情分析、智能问答等场景,获得可复用的工程结构与排错参考。

1. 中文复合事件抽取:从一句“如果…就…”里拆出事理图谱

电商风控团队拿到一条用户投诉:“因为快递延误,所以申请退款,结果客服拒绝,于是给了差评。”这句话里藏着条件、因果、顺承、反转四层事件关系,人工梳理一条要几分钟,一天几万条根本扛不住。中文复合事件抽取要做的,就是把这类句子自动拆成结构化的事件节点和关系边,最终拼成一张事理图谱——节点是事件,边是条件、因果、顺承、反转这些逻辑。它适合做舆情分析、风控归因、对话理解、知识库构建的工程师,也适合想从零搭一套中文事件抽取流水线的开发者。下面我按自己落地的顺序,把选型、代码、参数和踩过的坑一次讲清。

2. 复合事件抽取的四种关系:先分清边界再动手

2.1 条件、因果、顺承、反转到底怎么区分

很多人一上来就上模型,结果标注阶段就翻车。四种关系的判定标准必须先统一,否则标注一致性连 0.7 都到不了。

条件事件:前件是后件成立的前提,典型触发词“如果…就…”“只要…就…”“除非…否则…”。注意“如果”引导的不一定是条件,比如“如果我没记错,他昨天来过”是假设语气,不是事件条件。

因果事件:前件导致后件,触发词“因为…所以…”“由于…导致…”“…使得…”。因果和条件最容易混:条件是“没它不行”,因果是“有它才发生”。工程上我一般要求标注员用反事实测试——把前件去掉,后件还成立吗?成立就是条件,不成立就是因果。

顺承事件:时间或逻辑上的先后,触发词“然后”“接着”“于是”“随后”。顺承不要求因果,比如“他先吃饭,然后散步”,两件事没有因果关系,只是时间顺序。

反转事件:后件推翻前件的预期,触发词“但是”“然而”“却”“结果”。反转的关键是“预期违背”,不是简单的转折词。比如“虽然下雨了,但是比赛照常进行”,预期是下雨取消,实际照常,这才是反转。

实际语料里四种关系经常嵌套。我处理过一条:“如果审核不通过,就会触发人工复核,然后通知用户,但是用户往往直接投诉。”这里条件套因果,再套顺承,最后反转。所以抽取单元不能是整句,要先做事件切分。

2.2 事件切分:把长句拆成最小事件单元

事件切分的质量直接决定后面关系抽取的上限。我的做法是“触发词定位 + 论元边界扩展”。

触发词就是标志事件发生的词,通常是动词或动词短语。比如“申请退款”里“申请”是触发词,“退款”是论元。一个句子可能有多个触发词,每个触发词对应一个事件。

import jieba.posseg as pseg # 常见事件触发词表,实际项目里会持续扩充 TRIGGER_WORDS = { "申请", "拒绝", "延误", "投诉", "退款", "审核", "通知", "触发", "导致", "取消", "通过", "失败", "完成", "发起" } def split_events(sentence): """ 基于触发词做粗切分,返回候选事件列表。 每个事件包含:触发词、位置、左右窗口文本。 """ words = list(pseg.cut(sentence)) events = [] for idx, (word, flag) in enumerate(words): if word in TRIGGER_WORDS and flag.startswith("v"): # 左右各取 6 个词作为论元候选窗口 left = "".join(w for w, _ in words[max(0, idx-6):idx]) right = "".join(w for w, _ in words[idx+1:idx+7]) events.append({ "trigger": word, "position": idx, "left_context": left, "right_context": right }) return events s = "因为快递延误,所以申请退款,结果客服拒绝,于是给了差评" for e in split_events(s): print(e)

这段代码的逻辑:先用 jieba 做词性标注,只保留动词性触发词;然后以触发词为中心,左右各取 6 个词作为论元候选窗口。参数说明:窗口大小 6 是我在电商客服语料上试出来的,太小会丢论元,太大会引入噪声。触发词表需要根据业务领域扩充,通用词表在垂直场景召回率通常只有 60% 左右。

切分完要做合并:如果两个触发词距离小于 3 个词,且共享论元,合并成一个复合事件。这一步能减少 30% 以上的碎片事件。

2.3 关系抽取的两种路线:规则打底,模型提召回

关系抽取我走过两条路,最后用的是混合方案。

纯规则路线:用触发词对和连接词做模板匹配。比如检测到“因为…所以…”且前后各有一个事件,就标因果。优点是可控、可解释、冷启动快;缺点是召回低,换个说法就失效。

纯模型路线:用 BERT 做序列标注或关系分类。优点是泛化好;缺点是需要标注数据,中文复合事件的关系标注成本极高,一条句子可能要标 5 分钟。

我的混合方案:规则先跑一遍,高置信度的直接输出;低置信度的送模型做二分类。规则覆盖率大概 55%,模型在剩余部分能把整体召回拉到 82% 左右。

import re # 关系模板:连接词 -> 关系类型 RELATION_PATTERNS = [ (r"如果.*?就", "condition"), (r"只要.*?就", "condition"), (r"因为.*?所以", "causal"), (r"由于.*?导致", "causal"), (r"然后|接着|于是|随后", "sequential"), (r"但是|然而|却|结果", "contrast"), ] def rule_based_relation(sentence, events): """ 基于连接词和事件位置做关系判定。 events 是 split_events 的输出,按 position 排序。 """ relations = [] for pattern, rel_type in RELATION_PATTERNS: for match in re.finditer(pattern, sentence): start, end = match.span() # 找连接词前后最近的事件 before = [e for e in events if e["position"] < start] after = [e for e in events if e["position"] > end] if before and after: relations.append({ "head": before[-1]["trigger"], "tail": after[0]["trigger"], "type": rel_type, "confidence": 0.9 # 规则命中给高置信度 }) return relations

逻辑说明:对每种关系模板做正则匹配,找到连接词位置后,取前面最近的事件作为头事件,后面最近的事件作为尾事件。参数说明:confidence 设 0.9 是经验值,规则命中的准确率通常能到 85% 以上,但召回有限。实际部署时会把规则结果和模型结果做加权融合,权重根据验证集调。

2.4 事理图谱的存储结构:节点和边怎么设计

事理图谱本质是有向图,节点是事件,边是关系。存储方案我对比过三种:

方案写入速度查询速度适合规模备注
Neo4j中快千万级图查询语言成熟,运维成本高
NetworkX + 内存快快百万级重启丢数据,适合原型
关系库双表快中亿级事件表 + 关系表,用 SQL 查

我最终选的是关系库双表,因为团队更熟 SQL,且事理图谱的查询大多是“找某个事件的下游”或“找两个事件之间的路径”,用递归 CTE 就能搞定。

-- 事件表 CREATE TABLE events ( event_id TEXT PRIMARY KEY, trigger_word TEXT, sentence TEXT, doc_id TEXT, create_time TIMESTAMP ); -- 关系表 CREATE TABLE relations ( relation_id TEXT PRIMARY KEY, head_event_id TEXT, tail_event_id TEXT, relation_type TEXT, -- condition/causal/sequential/contrast confidence REAL, FOREIGN KEY (head_event_id) REFERENCES events(event_id), FOREIGN KEY (tail_event_id) REFERENCES events(event_id) ); -- 查某个事件的所有下游因果链 WITH RECURSIVE causal_chain AS ( SELECT tail_event_id, 1 AS depth FROM relations WHERE head_event_id = 'evt_001' AND relation_type = 'causal' UNION ALL SELECT r.tail_event_id, c.depth + 1 FROM relations r JOIN causal_chain c ON r.head_event_id = c.tail_event_id WHERE r.relation_type = 'causal' AND c.depth < 5 ) SELECT e.* FROM events e JOIN causal_chain c ON e.event_id = c.tail_event_id;

参数说明:递归深度限制 5 层是防止环导致无限递归,实际业务里因果链超过 5 层的基本是噪声。confidence 字段用于后续过滤,低于 0.6 的边在查询时可以排除。

3. 用 Python 跑通最小可用流水线:从句子到图谱

3.1 环境准备与依赖选择

Python 版本建议 3.9 以上,主要依赖四个库:jieba 做分词和词性标注,transformers 做模型推理,networkx 做图结构验证,sqlite3 做本地存储。不推荐一上来就上 Neo4j,原型阶段用 SQLite 足够,迁移成本也低。

pip install jieba transformers torch networkx

模型选型上,中文关系抽取我试过三个基座:BERT-base-chinese、RoBERTa-wwm-ext、以及一个轻量级的 ERNIE。在自建测试集上,RoBERTa-wwm-ext 的 F1 比 BERT 高 2.3 个点,但推理速度慢 15%。如果追求吞吐,ERNIE 轻量版在损失 1.8 个点的情况下速度快一倍。我一般先用 RoBERTa 跑基线,上线时根据 QPS 要求决定是否换轻量模型。

3.2 事件抽取的完整代码实现

下面是一个可运行的最小实现,把切分、关系判定、图谱构建串起来。

import jieba.posseg as pseg import re import networkx as nx import sqlite3 import uuid TRIGGER_WORDS = { "申请", "拒绝", "延误", "投诉", "退款", "审核", "通知", "触发", "导致", "取消", "通过", "失败", "完成", "发起" } RELATION_PATTERNS = [ (r"如果.*?就", "condition"), (r"只要.*?就", "condition"), (r"因为.*?所以", "causal"), (r"由于.*?导致", "causal"), (r"然后|接着|于是|随后", "sequential"), (r"但是|然而|却|结果", "contrast"), ] def extract_events(sentence): words = list(pseg.cut(sentence)) events = [] for idx, (word, flag) in enumerate(words): if word in TRIGGER_WORDS and flag.startswith("v"): left = "".join(w for w, _ in words[max(0, idx-6):idx]) right = "".join(w for w, _ in words[idx+1:idx+7]) events.append({ "event_id": str(uuid.uuid4())[:8], "trigger": word, "position": idx, "left_context": left, "right_context": right, "sentence": sentence }) return events def extract_relations(sentence, events): relations = [] for pattern, rel_type in RELATION_PATTERNS: for match in re.finditer(pattern, sentence): start, end = match.span() before = [e for e in events if e["position"] < start] after = [e for e in events if e["position"] > end] if before and after: relations.append({ "relation_id": str(uuid.uuid4())[:8], "head": before[-1]["event_id"], "tail": after[0]["event_id"], "type": rel_type, "confidence": 0.9 }) return relations def build_graph(events, relations): G = nx.DiGraph() for e in events: G.add_node(e["event_id"], trigger=e["trigger"], sentence=e["sentence"]) for r in relations: G.add_edge(r["head"], r["tail"], type=r["type"], confidence=r["confidence"]) return G def save_to_sqlite(events, relations, db_path="event_graph.db"): conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("""CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, trigger_word TEXT, sentence TEXT)""") c.execute("""CREATE TABLE IF NOT EXISTS relations ( relation_id TEXT PRIMARY KEY, head_event_id TEXT, tail_event_id TEXT, relation_type TEXT, confidence REAL)""") for e in events: c.execute("INSERT OR REPLACE INTO events VALUES (?,?,?)", (e["event_id"], e["trigger"], e["sentence"])) for r in relations: c.execute("INSERT OR REPLACE INTO relations VALUES (?,?,?,?,?)", (r["relation_id"], r["head"], r["tail"], r["type"], r["confidence"])) conn.commit() conn.close() # 跑一条完整流水线 sentence = "因为快递延误,所以申请退款,结果客服拒绝,于是给了差评" events = extract_events(sentence) relations = extract_relations(sentence, events) G = build_graph(events, relations) save_to_sqlite(events, relations) print("事件节点:", [(e["trigger"], e["event_id"]) for e in events]) print("关系边:", [(r["type"], r["head"], r["tail"]) for r in relations]) print("图谱节点数:", G.number_of_nodes(), "边数:", G.number_of_edges())

逻辑说明:extract_events 做触发词定位和论元窗口截取;extract_relations 用正则模板匹配连接词,再根据事件位置确定头尾;build_graph 用 networkx 建图做可视化验证;save_to_sqlite 持久化到本地库。参数说明:论元窗口 6 个词是经验值,触发词表需要按领域扩充,confidence 固定 0.9 只适用于规则命中,后续接模型时要改成模型输出概率。

3.3 从抽取结果到事理图谱的落地步骤

拿到事件和关系后,不要直接入库,先做三步清洗。

第一步,去重。同一句话里相同触发词且论元重叠超过 70% 的事件合并。我遇到过“申请退款”和“申请售后”被切成两个事件,实际是同一件事的不同表述。

第二步,补边。规则和模型都可能漏关系,用传递性补:如果 A 因果 B,B 顺承 C,那么 A 到 C 至少存在一条路径,可以补一条弱因果边,confidence 打 0.5。这一步能把图谱连通率提升 20% 左右。

第三步,环检测。事理图谱里出现环通常是抽取错误,比如“A 导致 B,B 导致 A”。用 networkx 的 simple_cycles 检测,发现环就人工复核,或者直接删掉 confidence 最低的那条边。

def clean_graph(G): # 去重:合并触发词相同且论元重叠的事件 # 补边:基于路径传递性补弱关系 # 环检测:删除低置信度边打破环 cycles = list(nx.simple_cycles(G)) for cycle in cycles: edges = [(cycle[i], cycle[(i+1) % len(cycle)]) for i in range(len(cycle))] min_edge = min(edges, key=lambda e: G[e[0]][e[1]].get("confidence", 0)) G.remove_edge(*min_edge) return G

参数说明:环检测只在图规模小于 10 万节点时全量跑,超过就采样跑。补边的传递深度限制 3 跳,再远噪声太大。

4. 避坑指南:复合事件抽取最容易翻车的五个地方

4.1 触发词表覆盖不全导致事件漏抽

现象:一条句子里明明有“驳回”“否决”这类词,但事件列表是空的。原因:触发词表只收了通用动词,没覆盖业务同义词。解决:用词向量做近邻扩展,把“拒绝”的近义词“驳回、否决、回绝、谢绝”都加进去;同时每周从 badcase 里补充新词。我一般维护一个触发词表版本文件,每次更新记录新增词和来源。

4.2 连接词歧义导致关系类型判错

现象:“结果”被一律判成反转,但“结果出来了”里的“结果”是名词,不是连接词。原因:正则只匹配词形,没做词性过滤。解决:匹配连接词后加一步词性校验,要求该词在句中是连词或副词。具体做法是用 jieba 的词性标注结果,只保留 flag 为 c 或 d 的匹配。

4.3 嵌套事件切分粒度不一致

现象:同一批语料,有人把“申请退款”当一个事件,有人切成“申请”和“退款”两个。原因:标注规范没定义最小事件单元。解决:明确规则——一个触发词对应一个事件,论元通过窗口扩展;如果两个触发词构成动宾结构且共享主语,合并为一个事件。这条规则写进标注手册,一致性能从 0.65 提到 0.85。

4.4 图谱环导致递归查询死循环

现象:SQL 递归查询跑了几分钟不出结果。原因:图谱里有环,递归 CTE 没有深度限制。解决:查询必须加 depth 限制,同时入库前跑环检测。我一般设 depth < 5,超过的边在业务上基本没意义。

4.5 模型和规则结果冲突时没有仲裁机制

现象:规则说是因果,模型说是顺承,两条边都入库了。原因:融合策略缺失。解决:定义优先级——规则高置信度(>0.85)直接采纳;规则低置信度和模型冲突时,取模型结果;两者都低于 0.6 的边直接丢弃。仲裁逻辑要写成独立函数,方便后续调权重。

5. 进阶技巧:用路径查询验证事理图谱的连通性

图谱建完不是终点,能查才有价值。最实用的验证方法是路径查询:给定两个事件,看它们之间是否存在条件或因果链。比如风控场景里,从“用户投诉”反查“快递延误”,如果存在因果路径,就能自动归因。

def find_causal_path(G, start_trigger, end_trigger, max_depth=5): """ 在事理图谱中查找从 start_trigger 到 end_trigger 的因果路径。 只走 causal 和 condition 类型的边。 """ start_nodes = [n for n, d in G.nodes(data=True) if d.get("trigger") == start_trigger] end_nodes = [n for n, d in G.nodes(data=True) if d.get("trigger") == end_trigger] paths = [] for s in start_nodes: for e in end_nodes: try: for path in nx.all_simple_paths(G, s, e, cutoff=max_depth): # 校验路径上的边类型 valid = all( G[path[i]][path[i+1]].get("type") in ("causal", "condition") for i in range(len(path)-1) ) if valid: paths.append(path) except nx.NetworkXNoPath: continue return paths

逻辑说明:先按触发词找到起止节点,再用 all_simple_paths 枚举所有简单路径,最后过滤掉包含非因果边的路径。参数说明:max_depth 设 5 是平衡召回和性能,超过 5 跳的路径在业务上很难解释。这个函数在万级节点图谱上单次查询约 200ms,可以接受。

另一个进阶用法是子图模式匹配。比如要找“条件 → 因果 → 反转”这种三段式结构,可以用 networkx 的 subgraph isomorphism 做匹配。我一般用它来发现高频事理模式,反哺规则模板。

最后说个血泪经验:事理图谱的质量不取决于模型多强,而取决于触发词表和关系模板的维护频率。我见过太多团队模型调了三个月,触发词表还是初始版本,召回率卡在 60% 上不去。每周花半小时看 badcase、补词表、调模板,比换模型管用得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进