ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

中文信息抽取实战:实体、关系、事件抽取的标注与模型实现

中文信息抽取实战:实体、关系、事件抽取的标注与模型实现 简介面向自然语言处理学习者与知识图谱构建人员这份中文信息抽取资源覆盖实体抽取、关系抽取与事件抽取三大核心任务。代码基于TensorFlow与bert4keras实现同时给出keras、tensorflow-gpu、h5py等依赖的版本配置便于快速搭建可复现的实验环境。压缩包共15个文件以9个Python脚本为主体分别实现基于全局指针GlobalPointer与BERT-CRF的实体抽取、CASREL与全局指针的关系抽取以及全局指针的事件抽取方案另有3个JSON文件存放预测结果与数据集样例3个占位文件用于保持目录结构整体大小仅5.15MB轻量易用。目前已有308人学习使用。资源目录按ner、re、ee三个模块清晰划分可直接对照人民日报语料等数据进行训练与预测从数据读取到模型推理链路完整适合希望深入理解中文信息抽取模型原理、需要参考完整代码实现的中高级NLP学习者。1. 中文信息抽取的项目骨架实体抽取、关系抽取、事件抽取中文信息抽取的目标是把非结构化文本变成结构化记录实体抽取、关系抽取、事件抽取是它的三个核心出口。实体抽取回答“谁、哪个组织、哪个地点”关系抽取回答实体之间的语义边比如控股、任职、转让事件抽取则把一句话折叠成带有触发词、发生时间和参与者角色的记录。三者配合起来才谈得上知识图谱构造、文本检索和档案结构化。适合的人群不只是算法工程师还有做数据中台、要打通“文本到表格”通道的工程人员。很多人拿到这类项目压缩包后第一件事是跑通示例但真正决定落地效果的其实是这三类任务的输入输出如何对齐下面从标注口径讲到模型实现再讲到结果合并的排错技巧。2. 中文信息抽取的标注口径实体、关系、事件共用一套 Schema2.1 用一份 JSON 同时容纳三类任务在接模型之前先把标注口径定住。公开项目里的示例数据通常把实体、关系、事件分成三个独立文件跑的时候各算各的等要合并知识图谱时才发现同一个“蓝河科技”在实体表是一段 span在关系表是另一种写法在事件表里又带着标点。我的做法是从第一行数据开始就用统一 JSON 记录关系与事件都通过实体 id 引用锚点而不是重复存文本。{ text: 蓝河科技六月以2亿元收购拓维软件100%股权。, entities: [ {id: 0, start: 0, end: 3, type: ORG, text: 蓝河科技}, {id: 1, start: 4, end: 5, type: DATE, text: 六月}, {id: 2, start: 7, end: 9, type: MONEY, text: 2亿元}, {id: 3, start: 12, end: 15, type: ORG, text: 拓维软件} ], relations: [ {head: 0, tail: 3, type: 股权收购} ], events: [ { trigger: {start: 10, end: 11, text: 收购}, type: 股权收购, arguments: [ {role: 买方, entity_id: 0}, {role: 标的, entity_id: 3}, {role: 金额, entity_id: 2} ] } ] }这样设计的原因在于三类任务的最小记录单位不同但都围绕实体展开。任务最小记录记录内容与实体的关系实体抽取entityid、span、类型、文本全局唯一 id关系抽取relationhead、tail、关系类型head/tail 都引用 entity.id事件抽取eventtrigger、type、argumentsarguments 里的角色指到 entity.id 或直接是文本2.2 用 BIOES 标注实体用闭区间规避边界错位实体抽取的标签方案推荐用 BIOES 而不是 BIO。原因在于 E 标记能让解码器明确知道 span 结束单字实体用 S。中文语料里经常出现单字地名例如“沪”“浙”用 BIO 会把单字实体拆成 B、I 两段多出一条边界后续关系抽取里对齐实体 id 时很容易出错。给一个可复用的标签生成函数def make_bioes(tokens, spans, is_closedTrue): labels [O] * len(tokens) for start, end, entity_type in spans: if not is_closed: end - 1 span_len end - start 1 if span_len 1: labels[start] fS-{entity_type} else: labels[start] fB-{entity_type} for i in range(start 1, end): labels[i] fI-{entity_type} labels[end] fE-{entity_type} return labels函数默认按“左闭右闭”的 span 处理。最典型的错误是把 end 当成 Python 切片参数标注时多一位或少一位标签和原文对不上模型再强也学不出正确边界。把 JSON 里出现过的闭区间规范统一用一个函数转换训练、评测、合并都用同一套代码能少踩一半边界坑。另一个要提前定死的是实体类型的大小写与缩写ORG、PER、GPE、MONEY、DATE 这五类在中文项目里最常用关系抽取依赖的类型必须和实体类型完全一致否则后面做候选过滤时会出现“MONEY 实体想参与买方角色”这种低级冲突。2.3 最小数据集的规模与长度预分析很多业务没有现成标注数据最常见做法是先用公开语料预训练模型再标注 80 到 120 条业务句子做微调。这个规模跑通全流程足够但要让效果稳定需要按事件类型分层采样每类事件至少 30 个正例。先统计样本长度再决定窗口代码from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) lengths [] for row in records: ids tokenizer.encode(row[text], add_special_tokensFalse) lengths.append(len(ids)) lengths.sort() p90 lengths[int(len(lengths) * 0.9)] print(90% 句子长度, p90, 最大, lengths[-1])我一般取 p90 再加 16 个 token 作为 max_len既不会让窗口普遍超长又给特殊符号留余量。这里有个容易被忽略的点长句直接截断会丢掉句尾的事件论元比如“蓝河科技收购拓维软件后将保留原管理团队并继续投入研发”这句话截断位置不同结果差异很大。所以预处理阶段先按句号、分号分句超过窗口的句子拆成多个样本而不是硬截。3. 实体抽取和关系抽取用 BERT 还原最小可跑的 PyTorch 流程3.1 实体抽取模型BERT 加线性分类头的损失计算实体抽取最常见做法是预训练语言模型加 token 级分类。这里用 Hugging Face Transformers 加载 BERT分类头输出每个 token 对应标签的概率padding 位置通过 ignore_index 排除在损失之外。代码import torch from torch import nn from transformers import AutoModel class NERModel(nn.Module): def __init__(self, model_namebert-base-chinese, num_labels9, dropout0.1): super().__init__() self.encoder AutoModel.from_pretrained(model_name) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(self.encoder.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, labelsNone): outputs self.encoder(input_ids, attention_maskattention_mask) seq_output self.dropout(outputs.last_hidden_state) logits self.classifier(seq_output) loss None if labels is not None: loss_fn nn.CrossEntropyLoss(ignore_index-100) loss loss_fn(logits.view(-1, logits.size(-1)), labels.view(-1)) return logits, lossignore_index-100是为了让[CLS]、[SEP]和 padding 位不参与反传构造 batch 时把这三类位置的标签统一填成 -100。这里要特别留意中文分词粒度BERT 中文模型以字为粒度但英文字母和数字会被拆成 subword例如“2亿元”中的“2”可能单独成一个 token。模型拿到的 token 序列比原始文本长解码阶段必须使用 tokenizer 返回的 offset_mapping 把 token 位置映射回字符位置否则实体起点终点全偏。3.2 实体解码用合法转移规则不直接裸 argmax直接对 logits 做 argmax 会得到不合法标签序列比如出现B-ORG后跟B-PER两个连续 B 之间没有 E解码器不知道前一个 span 在哪里结束。训练完成后我先用规则解码def decode_spans_with_rules(token_tags): spans [] start, cur_type -1, None for i, tag in enumerate(token_tags): if tag O: if start ! -1: spans.append((start, i - 1, cur_type)) start, cur_type -1, None continue bio, etype tag.split(-, 1) if bio B: if start ! -1: spans.append((start, i - 1, cur_type)) start, cur_type i, etype elif bio I: if start -1 or cur_type ! etype: continue elif bio E: if start ! -1 and cur_type etype: spans.append((start, i, cur_type)) start, cur_type -1, None elif bio S: if start ! -1: spans.append((start, i - 1, cur_type)) spans.append((i, i, etype)) start, cur_type -1, None if start ! -1: spans.append((start, len(token_tags) - 1, cur_type)) return spans规则逻辑是B 打开一个新实体I 和 E 只能跟在同类型的 B 或 I 后面S 自己开自己收。规则解码虽然不像 CRF 那样考虑整条路径的最优转移但足够用于内部验证和问题排查。如果需要更严格可以在 CRF 层把相邻标签的转移矩阵学出来再用维特比解码。显存不够时把 BERT 的 embedding 层冻结只让最后 6 层参与全量微调实体抽取效果损失通常在 1 个点以内。3.3 关系抽取用实体标记法把任务改成句子分类关系抽取不需要和实体抽取强行共享一个模型。关系类型少于 50 类时最常见做法是先跑实体抽取得到 subject 和 object 的 span然后把 span 用特殊符号包起来再送入关系分类模型。构造输入的函数如下def build_relation_input(text, subj_span, obj_span): marks [] for name, (start, end) in [(H, subj_span), (T, obj_span)]: marks.append((start, end, name)) marks.sort() pieces, prev [], 0 for start, end, name in marks: pieces.append(text[prev:start]) pieces.append(f[{H if name H else T}]) pieces.append(text[start:end 1]) pieces.append(f[/{H if name H else T}]) prev end 1 pieces.append(text[prev:]) return .join(pieces)把拼接后的文本交给AutoModelForSequenceClassification输出是预定义关系类型加一个“无关系”类。这么做的好处是复用实体抽取的边界结果不需要重新学习实体定位。需要约束的场景包括实体 span 过宽会拉长序列处理前先丢弃 head、tail 重叠的样本subject 和 object 的顺序需要固定通常按文本先后排而不是按句子语法主宾嵌套实体要提前定义好取外层还是内层否则关系分类看到的上下文不稳定。3.4 联合训练时控制两个 loss 的尺度如果实体与关系使用同一个 BERT 底座总损失建议按ner_loss relation_weight * relation_loss相加。relation_weight 从 0.5 开始调关系任务收敛慢可以逐步加到 1.0不要一开始就设 2.0否则关系任务主导表示实体边界会变差。常用参数可参考下表。参数实体任务常用值关系任务常用值说明learning_rate2e-53e-5关系分类头可以从 5e-5 起max_seq_len128256关系输入要额外加 4 个标记batch_size168实体任务显存占用更小re_weight1.00.5多任务相加时使用关系任务最容易被忽略的是负例。语料里没有关系的句子也要进入训练并按正例:负例 1:3采样否则模型会把任意两个实体之间的标记都判成有关系。这个比例不是固定值但负例太少结果会出现大批伪关系例如“蓝河科技六月以2亿元”里把“蓝河科技”和“六月”判成时间关系。4. 事件抽取的触发词识别与论元填充两级级联4.1 事件先拆两级触发词与论元事件抽取比关系抽取高一层关系抽取回答“A 和 B 有没有边”事件抽取回答“一句话里发生了什么事谁参与”。业务上常用的事件类型可以参考下表。事件类型典型触发词论元角色股权收购收购、入股、并购、受让买方、卖方、标的、金额、时间公司上市上市、挂牌、IPO公司、交易所、时间、募资额高管变动出任、任职、卸任、辞职公司、人物、职位、时间事件论元往往就是实体抽取已经覆盖的实体买方是 ORG人物是 PER金额是 MONEY。所以不建议单独再做一套论元实体标注否则会训练出两个边界不一致的实体模型合并时大量回退。工程上的常见做法是把触发词识别和论元填充做成两级模型先判断事件类型再在该类型允许的角色里填实体。4.2 触发词标注与事件类型分类触发词通常落在一小段 span 上比如“收购”就是两个字符。数据构造时先按事件 schema 生成触发词记录EVENT_SCHEMA { 股权收购: [买方, 卖方, 标的, 金额, 时间], 公司上市: [公司, 交易所, 时间, 募资额], } def build_event_records(records): train_data [] for rec in records: for ev in rec[events]: trigger ev[trigger] label ev[type] if trigger[start] is not None: train_data.append({ text: rec[text], trigger_start: trigger[start], trigger_end: trigger[end], trigger_text: trigger[text], event_type: label }) return train_data预测阶段有两种做法一是把触发词当成序列标注任务在 NER 的 label set 里加一个TRIGGER类型二是先用词表做候选召回再对候选片段做分类。两者的区别在于召回方式。词表召回适合事件类型固定、触发词变化不大的业务比如“收购、入股、并购、受让”基本能覆盖 80% 的股权收购事件序列标注适合触发词表达更灵活的文本但需要更多标注数据否则模型会在“引入”“转让”这类词上乱触发。触发词的粒度也要统一规则先把“拟收购”“完成收购”里的“拟”“完成”切掉只保留核心动词。4.3 论元填充的候选裁剪与角色去重论元填充的输入包含触发词 span、句子文本、实体候选列表。不要把所有实体都灌进角色分类器先按实体类型过滤MONEY 类型不可能成为买方PER 类型在收购事件里更可能作为“卖方关键人”而不是“标的”。这两步过滤在中文语料里能砍掉约一半候选。剩余候选按与触发词的距离排序def rank_candidates(candidates, trigger_start, trigger_end): def distance(cand): start, end cand[start], cand[end] dist min(abs(start - trigger_end), abs(end - trigger_start)) return (dist, end - start) return sorted(candidates, keydistance)排序后把“触发词、候选实体 span、事件类型”拼接成输入交给角色分类器输出为角色标签。时间论元需要单独处理因为“六月”“2023年”这类值往往不是严格意义上的命名实体应该把 DATE 实体单独作为一个候选和类型过滤后的实体放一起排序。同一个事件出现两个相同角色时按模型概率取舍。还有一个中文特有现象句子出现“其”这类代词比如“蓝河科技收购拓维软件并吸收其团队”这里的“其”不是独立论元也不能简单映射到前面某个实体。语料里代指频率较高时预处理阶段要先做短指代消解把“其”指向的实体 id 复制到论元角色上否则论元填充的准确率会被明显拖低。5. 合并实体、关系、事件结果时的 3 个排错技巧5.1 先做 span 归一化再分配实体 id实体表、关系表、事件表来自不同任务但最终要合并到同一个实体 id 上。原始文本中的全角半角、括号和标点会造成同一个实体出现多个 id例如“字节跳动”和“字节跳动”会被当成两个实体。合并前先归一化import re def normalize_span(text): return re.sub(r[\s。、,.;;:()\[\]], , text).casefold() def same_entity(a, b): na, nb normalize_span(a), normalize_span(b) if not na or not nb: return False return na nb or na in nb or nb in na全称和简称是否视为同一个实体由业务决定但必须放在同一层归一化函数里。只要同一段文本在三个表里出现不同写法后续“这家公司参与过哪些事件”的查询就会断链。5.2 关系与事件论元的重复消解关系三元组里的 head 和事件论元里的“买方”通常指向同一个实体合并时以实体 id 为维度而不是文本为维度。既然实体表已经统一了 id关系表和事件表的输出就不要再用字符串全部改成 id 数组。如果仍出现同一个实体 id 对应两个冲突的 type例如既是 ORG 又是 PER多半是实体抽取边界错误要回看原始 span 的起点和终点而不是在合并层掩盖错误。5.3 阈值回看按概率而不是数量校验把每个抽取结果带概率保存成 jsonl人工复核时只挑概率在 0.7 到 0.95 之间的记录。边界错误的模型通常会故意把低分样本送过来。判断阈值是否合理的一个简单做法分别取 0.9 和 0.7 两档看新增结果里正确样本的比例如果低于 30%就不要降阈值。最终导出知识图谱之前把关系类型列表做一次枚举校验防止空字符串、未知类型从数据集里悄悄进入线上链路。本文还有配套的精品资源点击获取
返回列表