2026/9/13 16:33:44

中文事件抽取:触发词与命名实体联合标注的序列建模实践

中文事件抽取:触发词与命名实体联合标注的序列建模实践 简介面向自然语言处理课程设计与期末大作业场景这套基于Python实现的中文事件触发词抽取与命名实体识别项目源码整合了数据预处理、特征构建、模型训练与评测等完整流程。代码涵盖LSTM、CNN等典型网络结构并配有多个Jupyter Notebook作为分步演示适合需要从零搭建NLP基线项目的学习者参考。压缩包共62个文件以Python脚本与ipynb笔记为核心同时包含数据集、依赖配置和使用说明整体约391.79MB。目前已有298人学习下载。通过源码目录与运行文档可快速了解事件触发词识别、实体标注与F1评测的实现细节并基于MSRA、ACE等语料进一步调优是一份可直接运行、便于二次开发的课程项目模板。1. 事件触发词抽取不是NER的副产品是事件抽取最开始的那道漏斗同样是一篇财经公告里面同时出现“收购”“签署”“发布”让模型判断发生了什么事件——大多数初级方案会把“收购”当作主角但真正触发“股权交易”事件的往往是“签署”这个词。这里的“签署”就是事件触发词Event Trigger它决定一篇文本里发生过哪种类型的事件而命名实体识别NER负责把参与事件的“公司名、人名、金额”从文本里抠出来。这两个任务在中文事件抽取里从来不是二选一而是前后串联触发词定位“发生了什么”NER填充“谁参与了”。这个项目标题把它们放进了同一个标注和模型框架里本质上是用一套序列标注模型同时产出事件边界和实体边界。适合正在做舆情分析、金融公告解析、司法文书信息化的工程师参考也是入门中文信息抽取时性价比最高的一条完整链路。2. 触发词与命名实体分开建模还是联合建模中文场景的序列标注方案2.1 触发词是“事件的门”命名实体是“门里的角色”事件抽取的标准流水线是先做事件检测再抽事件论元。事件检测的任务就是两件事定位触发词给它打上事件类型标签。比如“苹果发布新iPhone”里“发布”是触发词类型是“产品发布”“小米宣布收购某芯片公司”“收购”的触发词类型是“股权收购”。“触发词”不一定是动词中文里名词也可以承担这个功能比如“双方达成战略合作”“合作”是名词但它触发了“协议签署”事件。命名实体识别的输出是“人、组织、地点、时间、金额、产品”等实体类别这些实体在事件抽取里扮演的是论元候选的角色。一个事件由“触发词 一组论元”组成“收购”事件里的买方、卖方、交易标的、交易金额全部来自NER的结果。所以这两者的关系不是并列而是先后衔接且共享同一份句子上下文。很多人误以为触发词抽取只是NER的一个特例实际上触发词对上下文的依赖远高于实体——同一个词在不同语境下触发的事件类型完全不同“释放”在司法文书里触发“拘留释放”在环保新闻里触发“污染物排放”。2.2 用BIO标签把两类任务统一到一个序列标注框架里把事件触发和命名实体放在同一个模型里做最直接的理由是工程上省事其次是这两类标签在句子级别上存在互相约束。比如“某某公司起诉张三”“起诉”是触发词“某某公司”和“张三”都是论元如果模型已经把“起诉”预测成了“法律事件”触发词那么后面的“张三”是一个“被诉方”实体的概率就应该更高。联合建模能让这种隐含关联通过共享的BERT表征被模型自己学到。所以触发词标签和NER实体标签可以合并成一套BIO标签体系。触发词类别直接作为标签名例如标注含义示例B-Trade / I-Trade股权交易事件触发词“收购”中的B、IB-Finance / I-Finance金融财务事件触发词“融资”B-Legal / I-Legal法律事件触发词“起诉”B-PER / I-PER人名实体“张三”B-ORG / I-ORG组织实体“某某公司”O非触发词、非实体连词、助词训练时只有一个模型每个token的标签空间是“触发词类型 × BIO位置”加上“实体类型 × BIO位置”的并集。这个做法在工业界叫“统一序列标注”不是把两个任务串联跑两遍而是在一个输出层上同时解码。2.3 触发词标注里最容易做错的三个边界第一个边界是多词触发词。中文里“提起诉讼”“达成收购”“解除合同”这类短语触发词是“提起”“诉讼”两个词组合成的。如果只标其中一个下游事件判定就会偏。标注规则要明确连续两个词共同承担触发功能时用B-I连续标注并且类别保持一致如果两个词中间夹了别的词则算两个独立触发词。第二个边界是否定和情态。句子“法院驳回了诉讼请求”里的“驳回”本身触发了“判决”事件即使这件事的实质是“未支持诉求”。“未收购成功”里的“收购”仍然是触发词只是事件类型带否定标记BERT对这类现象有天然的上下文建模能力但标注时千万不能因为句意是否定就把触发词标成O。第三个边界是触发词与实体的重叠。“发布会”这个词既可以被切分为“发布”“会”也可以整体作为名词。在“召开新品发布会”里真正的触发词是“召开”而不是“发布会”。遇到这种重叠标注优先级是先标触发词再标实体同一个token不允许同时拥有两个标签属于触发词的位置就放弃实体标签。3. 中文事件语料的标注标签体系与数据准备3.1 事件类型是数据准备的起点先定类型再定标签中文事件抽取的公开数据源业内常用的是ACE2005的中文部分和百度DuEE事件数据集。ACE2005把事件分成8大类、33子类比如“Life”大类下包含“Born”“Die”“Injure”等子类。实际做中文项目时事件类型往往要根据业务重新裁剪金融场景只关心“上市”“融资”“减持”“回购”司法场景只关心“立案”“判决”“执行”。事件类型的数量直接决定标签体系的大小。子类数量控制在20个以内时BIO标签总数约等于“实体类型×2 事件类型×2 O”也就是40到60个标签。这个规模对BERTCRF来说是可训练的。如果一上来就定义上百种事件类型样本会被摊薄训练集里大量出现零次或一两次的类型模型推理时会把这些低频类全部吞进O。我一般建议第一个版本把子类压缩到10个以内先把pipeline跑通再用badcase分析拆细。3.2 一段中文新闻转成模型输入的完整流程3.2.1 原始句子与标注标签对应假设拿到这样一条标注好的训练数据{ text: 华兴资本宣布完成对某芯片公司的收购, events: [ {trigger: 收购, type: 股权交易, offset: [14, 16]}, {trigger: 宣布, type: 公告发布, offset: [4, 6]} ], entities: [ {mention: 华兴资本, type: ORG, offset: [0, 4]}, {mention: 某芯片公司, type: ORG, offset: [8, 13]} ] }需要把它转成“token BIO标签”的序列。中文字符按字切分每个字一个标签华兴资本宣布完成对某芯片公司的收购位置字标签0-3华/兴/资/本B-ORG, I-ORG, I-ORG, I-ORG4宣B-公告发布5-6布/完I-公告发布, O7成O8-12对/某/芯/片/公/司O, B-ORG, I-ORG, I-ORG, I-ORG, I-ORG13的O14-15收/购B-股权交易, I-股权交易注意“宣布”和“收购”同时存在这就是一个句子触发多个事件的典型样本模型必须学会每个token独立判断自己是不是触发词而不是整个句子只输出一个事件标签。3.2.2 BIO格式转换成模型输入转换成BERT输入时中文BERT按字切分不存在英文WordPiece那种“一个词被切成多个subword导致标签错位”的问题这是中文NLP项目相对省心的一个点。转换代码如下from typing import List, Tuple from transformers import BertTokenizer def build_inputs( char_seq: List[str], label_seq: List[str], tokenizer: BertTokenizer, label2id: dict, max_len: int 128 ) - Tuple[List[int], List[int], List[int]]: # char_seq: [华, 兴, 资, 本, ...] # label_seq: [B-ORG, I-ORG, ..., O] input_ids tokenizer.convert_tokens_to_ids(char_seq) label_ids [label2id[t] for t in label_seq] # 通用做法超出max_len直接截断头部保留句子前半部分 input_ids input_ids[:max_len] label_ids label_ids[:max_len] # padding并同时构造attention_mask pad_len max_len - len(input_ids) input_ids [tokenizer.pad_token_id] * pad_len label_ids [label2id[O]] * pad_len attention_mask [1] * (max_len - pad_len) [0] * pad_len return input_ids, attention_mask, label_ids这里有一个重要细节label_ids的padding不能填充数字0要填充label2id[O]对应的索引。原因很简单后面计算损失时如果某个位置的真实标签是O模型预测O不会惩罚但如果pad位置真实标签是0而0恰好是某个触发词类型模型在这些位置上的预测就会对损失产生错误贡献。所以所有padding位置都要用O的id去填同时配合attention_mask把这些位置从损失里排除。3.3 标签分布不均衡时的应对事件触发词在真实语料里的分布比NER的实体类型更极端。常见的高频触发词“发布”“宣布”“称”能占掉30%以上的样本“召回”“质押”这类低频触发词可能只有十几个样本。不处理这个问题CRF层会倾向于把所有边界都判给高频类型。缓解办法有三个层次。最简单的做法是让采样器按事件类型对句子做加权每种事件类型至少贡献一个batch。第二个做法是在损失函数上做标签频率加权类别权重的倒数和频率的平方根成正比而不是直接用反比避免少数类权重过大导致训练震荡。第三个做法是数据增强把同一类型事件的句子里的触发词替换成同义词比如“收购”替换成“并购”“买下”实体部分保持不变。替换后重新生成BIO标签可以让低位触发词类型学到更多样化的上下文。import math from collections import Counter def compute_class_weight(label_ids: List[int], num_classes: int) - List[float]: cnt Counter(label_ids) total sum(cnt.values()) weight [1.0] * num_classes for cls_id, freq in cnt.items(): if freq 0: weight[cls_id] math.sqrt(total / freq) return weight这个权重列表传入交叉熵损失函数的weight参数即可。注意这里用平方根而不是线性反比是防止低频类别权重过大把模型推向另一个极端。4. 基于BERT-wwm BiLSTM CRF的中文触发词抽取实现4.1 为什么选“预训练 BiLSTM CRF”而不是直接跑BERT分类现在做序列标注任务有很多选择直接用BERT加一个线性分类器也能跑为什么还要挂BiLSTM和CRF两个原因一是中文触发词存在明显的局部上下文依赖比如“正式宣布”“涉嫌非法吸收公众存款”中的“宣布”和“吸收”都是触发词BiLSTM能把这些上下文特征在序列维度上再融合一遍减轻BERT最后一层特征的局部噪声二是CRF层能约束标签转移关系例如“B-收购”后面只能接“I-收购”或O而不允许“B-收购”后面直接跟“B-诉讼”这种约束在纯softmax分类里是没有的。BERT-wwm是哈工大发布的中文预训练模型采用全词掩码策略对中文这种词边界不明显的语言在抽取类任务上往往优于原版BERT。常见做法是用hfl/chinese-bert-wwm-ext这个权重效果稳定且部署资源要求不高。4.2 模型结构与PyTorch代码骨架完整模型由四层构成BERT-wwm输出每个字的768维向量BiLSTM在序列维度上做双向编码输出拼成512维线性分类器把512维映射到标签数量最后接一个条件随机场层做序列解码。CRF层可以直接用pytorch-crf包import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class EventTriggerModel(nn.Module): def __init__(self, bert_path: str, num_labels: int, lstm_hidden: int 256): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, # 768 hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue, ) self.dropout nn.Dropout(0.3) self.classifier nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): # BERT输出: [batch, seq_len, 768] bert_out self.bert(input_idsinput_ids, attention_maskattention_mask)[0] # BiLSTM输出: [batch, seq_len, 512] lstm_out, _ self.bilstm(bert_out) lstm_out self.dropout(lstm_out) # 线性层输出各标签的发射分数 emissions self.classifier(lstm_out) if labels is not None: return -self.crf(emissions, labels, maskattention_mask.bool()) # 推理阶段用维特比算法解码出最优标签序列 return self.crf.decode(emissions, maskattention_mask.bool())这段代码里有两个关键点。forward里用的是CRF的负对数似然作为损失self.crf(emissions, labels, mask...)在pytorch-crf里返回的是对数似然取负后才能做最小化。推理时crf.decode返回的是一个list of list每个元素是合法转移约束下的全局最优路径而不是每个位置独立取argmax这是CRF带来的核心差异。4.3 训练脚本的关键参数与损失函数选择训练参数不能照搬文本分类任务的默认值事件触发词是细粒度标注任务对学习率和序列长度更敏感。一组能够稳定收敛的基准参数如下参数取值说明max_len128新闻和公告句子截断到这个长度覆盖绝大部分触发词上下文batch_size32显存不足时降到16过低会导致CRF转移矩阵不稳定learning_rate2e-5BERT部分的初始学习率超过5e-5容易摧毁预训练权重lstm_learning_rate1e-3BiLSTM和分类器用较大的学习率收敛更快warmup_ratio0.1前10%步数线性预热避免大学习率起步造成震荡epochs5标注数据量少时3-5轮足够再多会过拟合训练集gradient_clip5.0防止CRF层的梯度爆炸训练时要用分层学习率BERT层的参数走2e-5BiLSTM和分类器走1e-3两个学习率分别配置优化器的参数组from transformers import AdamW, get_linear_schedule_with_warmup bert_params [p for n, p in model.named_parameters() if bert in n] other_params [p for n, p in model.named_parameters() if bert not in n] optimizer AdamW([ {params: bert_params, lr: 2e-5}, {params: other_params, lr: 1e-3} ]) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps)4.4 训练过程中的两个监控点第一个监控点是开发集上的“触发词级别的F1”而不是整体的token准确率。token准确率会被大量O标签拉高哪怕模型把所有位置都预测成O准确率也有85%以上但这个模型完全没有用处。触发词级别的F1要把连续B-I片段合并成一个触发词再和真实触发词做精确匹配。第二个监控点是CRF转移矩阵的输出。训练初期打印一次转移矩阵的值正常情况下从B-到I-的转移分数应该显著高于其他非法转移。如果训练了几轮后转移分数仍然混乱说明数据里存在标签噪声大概率是3.2里的offset标注错了导致同一个触发词在不同句子里边界不一致。优先回头清理数据而不是继续调参。5. 触发词抽取的评估与预测严格匹配还是宽松匹配5.1 评估代码与三个级别的指标事件触发词的评估不能只看准确率核心指标是触发词级别的精确率、召回率和F1。匹配方式分两种严格匹配要求预测的触发词文本位置和事件类型都和真实标注完全一致宽松匹配只要求事件类型正确触发词边界允许偏差一个字符以内。工业界一般两个指标都报前者体现解码精度后者体现分类能力。def normalize_entities(label_seq, id2label): entities [] i, n 0, len(label_seq) while i n: label id2label[label_seq[i]] if label.startswith(B-): etype label[2:] j i 1 while j n and id2label[label_seq[j]] fI-{etype}: j 1 entities.append((i, j - 1, etype)) i j else: i 1 return set(entities) def calc_f1(pred_seqs, true_seqs, id2label, strictTrue): pred_entities, true_entities [], [] for pred, true in zip(pred_seqs, true_seqs): pred_entities.extend(normalize_entities(pred, id2label)) true_entities.extend(normalize_entities(true, id2label)) pred_set, true_set set(pred_entities), set(true_entities) tp len(pred_set true_set) precision tp / len(pred_set) if pred_set else 0.0 recall tp / len(true_set) if true_set else 0.0 f1 2 * precision * recall / (precision recall) if precision recall else 0.0 return precision, recall, f1normalize_entities函数做的事情是把BIO序列里的连续片段合并成三元组(起始位置, 结束位置, 类型)这个三元组和NER里“实体片段”的定义完全一致。strict参数在这个实现里体现在比较时是否要求边界一致严格匹配直接用三元组完全相等如果要做宽松匹配就把三元组改成(类型, 中间位置)只要类型一致且两个位置之间的距离小于阈值就认为匹配。5.2 从触发词到事件论元预测结果如何送去下游触发词抽取的产出是“某个token区间 事件类型”它本身还不能构成完整事件。以“华兴资本宣布完成对某芯片公司的收购”为例模型输出触发词“收购”且类型为“股权交易”接下来需要做的是以这个触发词为中心在句子范围内找出事件论元。最常见的做法是规则约束把触发词前后的命名实体都作为候选举入再按语义角色过滤。具体到代码层面实体是NER模块输出的“收购”前面的“华兴资本”按上下文语义推断为买方“收购”后面“某芯片公司”推断为卖方。这么做不需要额外训练一个论元角色分类器适合快速产出可用的事件结构化结果def extract_event_arguments(trigger_span, entity_list, sent_start, sent_end): # 按实体位置与触发词的相对关系粗略分配论元角色 args [] for ent_start, ent_end, ent_type in entity_list: if ent_end trigger_span[0]: role buyer if ent_type ORG else other elif ent_start trigger_span[1]: role target if ent_type ORG else other else: continue args.append({role: role, mention: ent, offset: [ent_start, ent_end]}) return args注意这里的角色分配是非常粗糙的只适合做baseline。线上系统若要准确区分“买方”“卖方”“交易标的”需要再加一层论元角色分类模型输入是触发词、候选论元和两者之间的依存路径。5.3 典型的误报类型与排查方向事件触发词模型上线后最常出现的误报是“事件类型混淆”典型的例子是把“公司发布年报”里的“发布”识别成“产品发布”而正确类型是“公告发布”。这种混淆无法通过调CRF转移矩阵解决只能增加训练数据或引入事件类型本身的层级约束——例如在标签体系中加入“公告发布”和“产品发布”这两个子类的父类约束解码时先预测大类再细分小类。第二种误报是“边界偏移”模型预测出了“收购”但把“被收购”也包含了进去。排查方法是把badcase按预测边界和真实边界的位置差做直方图统计如果大量样本的边界差为1或2个字符说明标注规范里对“触发词是否包含副词”的定义不够清晰。统一规则是触发词只包含核心谓词或名词不包含副词、助词、介词。6. 触发词抽取的三条实用技巧截断、词典与半自动标注6.1 句子截断时把触发词窗口保留下来max_len设为128后长新闻里的触发词可能被截断到序列之外。与其从句子开头截不如先做一个预扫描用触发词词典先粗略定位句中所有候选触发词的位置以每个候选触发词为中心取前后各32个字符的窗口如果窗口重叠就合并。这样进入模型的数据不是句子的前128个字而是“围绕候选触发词的上下文片段”。这个技巧能显著提升长文本里的事件召回率因为绝大多数中文触发词的判别依据都集中在前后两三个短句范围内。6.2 触发词词典注入而不是硬约束整理一份行业触发词词典例如“收购、并购、减持、质押、回购、起诉、判决”把它作为先验知识注入模型。注入方式不是把词典里的词强行替换进文本而是在模型的输入中追加一个辅助特征把每个字符标记为“词典命中”或“未命中”然后把这个特征向量和BERT输出拼接后再送入BiLSTM。dict_feat torch.tensor(dict_hit_mask, dtypetorch.float32).unsqueeze(-1) # [batch, seq, 1] lstm_input torch.cat([bert_out, dict_feat], dim-1)这么做的好处是模型能学到“词典命中位置更可能是触发词”的倾向但不会被词典完全绑定——词典之外的新触发词仍然有机会被预测出来。6.3 用半自动标注迭代出高质量语料高质量中文事件语料是最贵的资源。我一般会先用“触发词词典 依存句法规则”自动标注一批粗数据训练第一版模型再用这个模型对未标注语料做预标注工人只需在标注平台上修正错误而不是从零划线。通常两轮迭代后触发词的标注一致率能从75%提升到90%以上。迭代时尤其要关注低频事件类型对每个类型积累至少50个校正后的正样本模型才能稳定学会判别这类触发词的上下文模式。本文还有配套的精品资源点击获取