简介:面向自然语言处理学习者与知识图谱构建人员,这份中文信息抽取资源覆盖实体抽取、关系抽取与事件抽取三大核心任务。代码基于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. 中文信息抽取的标注口径:实体、关系、事件共用一套 Schema
2.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} ] } ] }这样设计的原因在于三类任务的最小记录单位不同,但都围绕实体展开。
| 任务 | 最小记录 | 记录内容 | 与实体的关系 |
|---|---|---|---|
| 实体抽取 | entity | id、span、类型、文本 | 全局唯一 id |
| 关系抽取 | relation | head、tail、关系类型 | head/tail 都引用 entity.id |
| 事件抽取 | event | trigger、type、arguments | arguments 里的角色指到 entity.id 或直接是文本 |
2.2 用 BIOES 标注实体,用闭区间规避边界错位
实体抽取的标签方案推荐用 BIOES 而不是 BIO。原因在于 E 标记能让解码器明确知道 span 结束,单字实体用 S。中文语料里经常出现单字地名,例如“沪”“浙”,用 BIO 会把单字实体拆成 B、I 两段,多出一条边界,后续关系抽取里对齐实体 id 时很容易出错。给一个可复用的标签生成函数:
def make_bioes(tokens, spans, is_closed=True): 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] = f"S-{entity_type}" else: labels[start] = f"B-{entity_type}" for i in range(start + 1, end): labels[i] = f"I-{entity_type}" labels[end] = f"E-{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_tokens=False) 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_name="bert-base-chinese", num_labels=9, dropout=0.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, labels=None): outputs = self.encoder(input_ids, attention_mask=attention_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_rate | 2e-5 | 3e-5 | 关系分类头可以从 5e-5 起 |
| max_seq_len | 128 | 256 | 关系输入要额外加 4 个标记 |
| batch_size | 16 | 8 | 实体任务显存占用更小 |
| re_weight | 1.0 | 0.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, key=distance)排序后把“触发词、候选实体 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%,就不要降阈值。最终导出知识图谱之前,把关系类型列表做一次枚举校验,防止空字符串、未知类型从数据集里悄悄进入线上链路。
本文还有配套的精品资源,点击获取