
简介这份资源面向自然语言处理初学者与进阶开发者聚焦中文复合事件抽取这一细分任务帮助解决条件、因果、顺承、反转等事件类型的识别与关系建模问题。包内共11个文件以6个xml配置、2张png示意图、1个md说明、1个py脚本及1个iml工程文件为主压缩包约553KB体量轻便便于快速浏览与二次开发。内容围绕事件抽取流程展开涵盖文本预处理、分词标注、模型训练与事理图谱构建等环节并配有条件事件、反转事件等示意图辅助理解。已有1533人学习下载说明该方向具备一定关注度。读者可从中获取可运行的Python源码骨架、工程目录组织方式与图谱构建思路适合作为事件抽取入门练手或课程设计参考也可在此基础上接入jieba、spaCy或BERT等工具做进一步扩展。1. 中文复合事件抽取从一句“如果…就…”里挖出事理图谱一句“如果明天下雨比赛就取消”普通信息抽取只能拿到“下雨”和“取消”两个事件但真正有价值的是它们之间的条件关系。中文复合事件抽取要做的就是把条件、因果、顺承、反转这四类逻辑关系从文本里识别出来再连成一张事理图谱。它解决的是“事件之间怎么互相影响”的问题适合做风控推演、舆情演化分析、智能问答和决策辅助的从业者。我做过一个模拟项目原始需求只是“把新闻里的因果链抽出来”结果发现条件句和反转句占了近四成不处理这些图谱就是一堆断头路。这篇笔记按“先立住概念、再跑通最小系统、最后填坑”的顺序写代码全部用 Python能直接抄。2. 四类复合事件怎么定义条件、因果、顺承、反转的判定边界复合事件抽取不是把两个事件随便连起来而是要先定义清楚“什么算条件、什么算因果”。这一步定歪了后面标注、建模、评估全跟着歪。我一般会先画一张判定表把触发词、句法标志和反例都列出来再动手写代码。2.1 四类关系的触发词与句法标志条件事件的核心是“假设—结果”触发词包括“如果、假如、一旦、只要、除非”。注意“只要”后面跟的是充分条件“除非”后面跟的是必要条件方向不能反。因果事件的触发词是“因为、由于、导致、使得、从而”它和条件事件最大的区别是因果句里原因已经发生或被认为真实条件句里前提是假设的。顺承事件靠时间顺序和动作连续性判断触发词有“接着、然后、随后、于是”但“于是”有时兼有因果意味需要看前后事件是否同一主体。反转事件最容易被忽略触发词是“但是、然而、却、反而、不料”它表示后一个事件对前一个事件的预期或结果形成否定。判定时我常用一个简单规则先看连接词再看前后事件的时态和情态。条件句常带“会、将、就”因果句常带“了、已经、正在”顺承句两个事件都是完成态反转句后件常带否定或相反极性。2.2 用规则基线快速标注一批种子数据没有标注数据时别急着上模型。先用规则跑一遍既能验证定义又能产出种子语料。下面这段代码用正则和简单句法特征做四类关系的粗分类。import re from typing import List, Tuple # 四类关系的触发词表按优先级排列反转 条件 因果 顺承 PATTERNS { 反转: r(但是|然而|可是|却|反而|不料|没想到), 条件: r(如果|假如|一旦|只要|除非|若是), 因果: r(因为|由于|导致|使得|从而|所以|因此), 顺承: r(接着|然后|随后|于是|之后|紧接着), } def classify_relation(sentence: str) - List[Tuple[str, str]]: 返回句子中命中的关系类型和触发词 hits [] for rel, pat in PATTERNS.items(): for m in re.finditer(pat, sentence): hits.append((rel, m.group())) # 按触发词位置排序位置靠前的优先作为主关系 hits.sort(keylambda x: sentence.find(x[1])) return hits def split_events(sentence: str, trigger: str) - Tuple[str, str]: 以触发词为界粗切前后两个事件片段 idx sentence.find(trigger) if idx -1: return , left sentence[:idx].strip(,。 ) right sentence[idx len(trigger):].strip(,。 ) return left, right if __name__ __main__: sents [ 如果明天降温水管就会冻裂。, 因为路面结冰所以早高峰发生了多起追尾。, 他先提交了申请然后等待审批结果。, 天气预报说晴天但是却下起了暴雨。, ] for s in sents: rels classify_relation(s) if rels: rel, trig rels[0] e1, e2 split_events(s, trig) print(f关系{rel} 触发词{trig} 事件1{e1} 事件2{e2})这段代码的逻辑是先按优先级匹配触发词再以触发词为切分点把句子粗切成两个事件片段。参数上PATTERNS的键顺序决定了优先级我把“反转”放最前因为“但是”常和“因为”共现先命中反转更符合直觉。split_events只做了标点剥离没有做分词和事件边界修正所以输出的是片段而不是标准事件。这个基线在模拟项目里对条件句的召回能到七成左右但精确率只有五成多主要错在“如果”引导的宾语从句被误判。用它来筛种子数据够用直接当最终结果不行。2.3 事件边界切分别让触发词把事件切碎规则切分最大的坑是把事件切碎。比如“因为下雨导致路面湿滑所以比赛取消”按“导致”切会得到“因为下雨”和“路面湿滑所以比赛取消”后件里还套着因果。我的做法是先做一次全句关系识别再按关系层级做递归切分。具体步骤是先找最高层级的触发词通常是句首或分句间的连词切出两个大事件如果某个大事件内部还有触发词再递归切一次。这样得到的才是嵌套结构而不是平铺的碎片。实现时可以用一个栈来管理切分区间每次切分后把左右区间压栈直到区间内没有触发词为止。注意递归深度要设上限一般三层足够再深就是模型该干的事了。3. 从规则到模型用 Python 搭一套可复现的抽取流水线规则基线只能做冷启动真要提升泛化得引入序列标注和关系分类。这一章给出一条从原始文本到事理图谱的完整流水线每一步都有可运行的代码和参数说明。3.1 数据预处理分句、分词与事件触发词标注中文没有空格分句和分词是第一步。我一般用re按标点分句再用jieba做分词和词性标注。事件触发词通常是动词或形容词标注时用 BIO 格式。import jieba.posseg as pseg import re def split_sentences(text: str): 按中文标点分句保留标点 parts re.split(r(?[。]), text) return [p.strip() for p in parts if p.strip()] def tokenize_with_pos(sentence: str): 分词并返回 (词, 词性) 列表 return [(w.word, w.flag) for w in pseg.cut(sentence)] def build_bio(sentence: str, trigger_words: set): 根据触发词表生成 BIO 标注序列 tokens tokenize_with_pos(sentence) labels [] for word, pos in tokens: if word in trigger_words: labels.append(B-TRIG) elif labels and labels[-1] B-TRIG and pos.startswith(v): labels.append(I-TRIG) else: labels.append(O) return tokens, labels if __name__ __main__: text 如果气温骤降管道就会冻裂。因为压力升高阀门因此损坏。 triggers {骤降, 冻裂, 升高, 损坏} for sent in split_sentences(text): tokens, labels build_bio(sent, triggers) print(list(zip(tokens, labels)))这里split_sentences用后向断言保留句末标点避免把“。”吞掉。build_bio里我加了一个简单规则触发词后的动词如果紧邻标成I-TRIG这是为了处理“冻裂”这类动补结构被切开的场景。参数上trigger_words需要人工维护初期可以先用第 2 章的规则命中结果自动扩充再人工过滤。注意jieba对领域词的分词可能不准比如“冻裂”可能被切成“冻”和“裂”所以触发词表里最好同时收录单字和双字形式。3.2 关系分类模型用 BERT 微调做四分类有了触发词标注下一步是判断两个事件之间的关系。我一般把关系分类建模成句对分类任务输入是“事件1 触发词 事件2”的拼接文本输出是四类关系之一。下面用transformers写一个最小可训练脚本。import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer, BertForSequenceClassification, AdamW class RelationDataset(Dataset): def __init__(self, samples, tokenizer, max_len128): self.samples samples # [(e1, trigger, e2, label_id), ...] self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): e1, trig, e2, label self.samples[idx] text f{e1}[SEP]{trig}[SEP]{e2} enc self.tokenizer( text, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(label, dtypetorch.long), } def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0 for batch in loader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss loss.backward() optimizer.step() optimizer.zero_grad() total_loss loss.item() return total_loss / len(loader) # 使用示例 if __name__ __main__: tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels4 ) # 标签映射0条件, 1因果, 2顺承, 3反转 samples [ (明天下雨, 如果, 比赛取消, 0), (路面结冰, 因为, 发生追尾, 1), (提交申请, 然后, 等待审批, 2), (预报晴天, 但是, 下起暴雨, 3), ] dataset RelationDataset(samples, tokenizer) loader DataLoader(dataset, batch_size2, shuffleTrue) optimizer AdamW(model.parameters(), lr2e-5) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(3): loss train_one_epoch(model, loader, optimizer, device) print(fepoch{epoch} loss{loss:.4f})这段代码的关键参数有三个max_len128对大多数中文事件句够用超过会截断短句会补齐lr2e-5是 BERT 微调的常用学习率再大容易震荡batch_size2是因为示例数据少实际训练建议 16 或 32。输入构造上我用[SEP]把事件1、触发词、事件2 拼在一起让模型能同时看到两个事件和连接词。注意BertTokenizer会把[SEP]当成特殊符号处理不要手动转成普通文本。训练时如果显存不够把max_len降到 64或者改用bert-base-chinese的蒸馏版本。3.3 事理图谱构建节点、边与存储格式关系分类输出的是三元组(事件1, 关系, 事件2)要变成图谱还得做节点归一化和边合并。节点归一化就是把“水管冻裂”和“管道冻裂”合并成同一个节点我一般用编辑距离加同义词表做粗合并再用词向量做细合并。边合并是把同一对事件之间的多条关系保留下来比如既有因果又有顺承就存成两条边。存储格式我推荐用 JSON Lines每行一个三元组本文还有配套的精品资源点击获取