简介:本资源是一套基于Python的实体关系抽取系统源码,面向具备一定深度学习基础、希望从文本中自动提取结构化实体与关系信息的学习者与开发者,可应用于高端装备制造领域知识图谱的自动化构建。项目以BERT等预训练模型为核心,涵盖数据加载与生成、模型定义与训练、FGM对抗性训练以及EMA参数平滑更新等模块,兼顾抽取准确率与模型鲁棒性。压缩包共22个文件,包含8个Python脚本、12个JSON配置与数据文件,以及1个说明文档和1个Markdown文件,整体约1.97MB,结构清晰,便于按模块阅读与二次开发。目前已有208人学习下载。通过该源码,读者可完整了解实体关系抽取从数据准备、模型搭建到训练优化的实现流程,掌握对抗训练与参数平滑等提升稳定性的实用技巧,并可直接运行调试,为知识图谱构建或相关竞赛项目提供可复用的工程参考。
1. 拆开这份 Python 实体关系抽取源码:从 JSON 标注到 BERT 推理的一条龙流程
高端装备制造领域的知识图谱构建,最头疼的往往不是模型选型,而是从一堆工艺文档、设备台账里把「谁和谁有什么关系」抽干净。这份基于 Python 的实体关系抽取系统源码,解决的正是这个环节:它把 BERT 预训练模型、对抗训练(FGM)、参数平滑更新(EMA)和一套完整的数据生成脚本打包在一起,输入是标注好的 JSON 文本,输出是结构化的实体关系三元组。适合两类人:一是手里有领域语料、想快速跑通关系抽取 baseline 的算法工程师;二是想研究 FGM 和 EMA 在 NER/RE 任务里怎么落地实现的学生或转行者。源码目录里gpNet.py、dataloader.py、main.py、predict.py各司其职,train.json、train_ccl.json、schemas.json等数据文件也一并给出,拿到手就能从训练走到推理,不用自己再搭一套脚手架。
2. 数据管线拆解:train.json 到 DataLoader 的字段映射与批处理
2.1 标注文件的结构与 schemas.json 的约束作用
先看数据长什么样。train.json和train_ccl.json是训练集,evalA.json、evalA2.json是验证集,submit_example_A.json是提交格式示例。这类关系抽取任务的标注文件通常是 JSON 数组,每条样本包含原始文本和实体关系标注。schemas.json定义了关系类型集合,相当于给模型一个封闭的关系标签空间——预测时只会输出 schema 里出现过的关系类型,不会凭空造新关系。
常见做法是每条样本的结构类似这样:
{ "text": "主轴箱与床身通过螺栓连接", "entities": [ {"id": 0, "type": "部件", "start": 0, "end": 3, "name": "主轴箱"}, {"id": 1, "type": "部件", "start": 4, "end": 6, "name": "床身"} ], "relations": [ {"head": 0, "tail": 1, "type": "连接关系"} ] }这里start/end是字符级偏移,head/tail指向 entities 数组的下标。schemas.json里则列出所有合法的type值。如果你的标注格式和这个不一致,dataloader.py在解析时会直接报 KeyError 或索引越界,所以第一步就是对齐字段名。
2.2 dataloader.py 的批处理逻辑与 tokenizer 对齐
dataloader.py负责把 JSON 样本转成模型可吃的 tensor。核心流程是:读 JSON → 用tokenizer.json(BERT 的 fast tokenizer)做子词切分 → 把字符级实体偏移映射到 token 级偏移 → padding 到统一长度 → 返回 batch。
关键坑在偏移映射。BERT 的 tokenizer 会把一个中文字符切成一个 token,但英文或数字可能被切成多个子词。如果实体边界落在子词中间,映射就会错位。源码里通常用tokenizer(text, return_offsets_mapping=True)拿到每个 token 对应的原始字符区间,再做对齐。我一般会加一段校验:
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("./pretrain_model") text = "主轴箱与床身通过螺栓连接" encoding = tokenizer(text, return_offsets_mapping=True, return_tensors="pt") offsets = encoding["offset_mapping"][0].tolist() for i, (s, e) in enumerate(offsets): print(i, tokenizer.convert_ids_to_tokens(encoding["input_ids"][0][i]), s, e)跑一遍就能看到每个 token 对应的字符区间。如果某个实体的start/end没有和任何 token 边界对齐,就需要在 dataloader 里做截断或扩展处理。参数上,max_length一般设 128 或 256,取决于你的文本平均长度;batch_size在单卡 8G 显存下建议 16 起步,OOM 就降到 8。
2.3 data_gen.py 与 data_genccl.py 的差异化生成策略
data_gen.py和data_genccl.py是两个数据生成脚本,从命名看ccl可能对应某种对比学习或课程学习(curriculum learning)的变体。常见做法是:data_gen.py生成标准训练集,data_genccl.py生成带难例挖掘或数据增强的版本,用于第二轮训练。两个脚本的输出分别对应train.json和train_ccl.json、train_ccl2.json。
如果你要替换成自己的数据,改这两个脚本里的输入路径和字段映射即可。注意train_ccl2.json可能是第二轮课程学习的产物,训练顺序上一般先跑train.json,再跑train_ccl.json,最后用train_ccl2.json做微调。这个顺序不要颠倒,否则模型会先学难例再学基础,收敛会变差。
3. gpNet.py 模型结构与 FGM 对抗训练的落地实现
3.1 BERT 编码层与关系分类头的拼接方式
gpNet.py是整个项目的模型定义文件。从摘要看,它基于 BERT 做实体关系抽取。典型结构是:BERT 输出每个 token 的隐状态 → 取实体 span 的起始 token 或平均池化 → 拼接 head 和 tail 的表示 → 过一个全连接层做关系分类。
import torch import torch.nn as nn from transformers import BertModel class RelationModel(nn.Module): def __init__(self, pretrain_path, num_relations): super().__init__() self.bert = BertModel.from_pretrained(pretrain_path) hidden = self.bert.config.hidden_size # head 和 tail 表示拼接后分类 self.classifier = nn.Linear(hidden * 2, num_relations) self.dropout = nn.Dropout(0.1) def forward(self, input_ids, attention_mask, head_idx, tail_idx): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) hidden = outputs.last_hidden_state # [B, L, H] head_vec = hidden[torch.arange(hidden.size(0)), head_idx] tail_vec = hidden[torch.arange(hidden.size(0)), tail_idx] logits = self.classifier(self.dropout(torch.cat([head_vec, tail_vec], dim=-1))) return logits这里head_idx和tail_idx是实体起始 token 在序列中的位置。参数上,num_relations等于schemas.json里关系类型的数量;dropout设 0.1 是常规起点,过拟合严重可以加到 0.3。如果你的实体有多个 token,可以把起始和结束 token 的表示拼起来,或者做 attention pooling,但源码里大概率用的是起始 token 表示,改的时候注意维度对齐。
3.2 FGM 对抗扰动的注入位置与 epsilon 取值
FGM(Fast Gradient Method)是一种对抗训练技巧,核心是在 embedding 层加一个沿梯度方向的扰动,让模型对输入的小变化更鲁棒。源码里应该有一个FGM类,在训练循环里调用attack()和restore()。
class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and "embedding" in name: self.backup[name] = param.data.clone() norm = torch.norm(param.grad) if norm != 0: r_at = self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.backup.items(): param.data = self.backup[name] self.backup = {}epsilon控制扰动幅度,常见取值 0.5 到 1.0。太大模型训不动,太小没效果。注入位置一般选 embedding 层,因为那里是连续表示,扰动语义上更合理。训练循环里先正常 forward/backward,再attack()后二次 forward/backward,最后restore()。注意attack()之前要确保梯度已经算出来,否则param.grad是 None。
3.3 EMA 参数平滑更新的实现与训练循环集成
EMA(Exponential Moving Average)维护一份模型参数的滑动平均副本,推理时用这份副本,通常能提升稳定性和泛化。源码里应该有一个EMA类。
class EMA: def __init__(self, model, decay=0.999): self.model = model self.decay = decay self.shadow = {} self.backup = {} for name, param in model.named_parameters(): if param.requires_grad: self.shadow[name] = param.data.clone() def update(self): for name, param in self.model.named_parameters(): if param.requires_grad: new_average = (1.0 - self.decay) * param.data + self.decay * self.shadow[name] self.shadow[name] = new_average.clone() def apply_shadow(self): for name, param in self.model.named_parameters(): if param.requires_grad: self.backup[name] = param.data param.data = self.shadow[name] def restore(self): for name, param in self.model.named_parameters(): if param.requires_grad: param.data = self.backup[name] self.backup = {}decay一般设 0.999,训练步数少可以降到 0.99。每个 step 或每个 epoch 结束后调update(),验证前调apply_shadow(),验证完调restore()。注意 EMA 的 shadow 是在训练过程中累积的,如果中途 resume 训练,shadow 不会自动恢复,需要额外保存和加载。
4. main.py 训练入口与 predict.py 推理链路的参数配置
4.1 main.py 的训练参数与启动命令
main.py是训练入口。常见参数包括:--pretrain_path指向pretrain_model目录,--train_file指向train.json,--schema_file指向schemas.json,--batch_size、--lr、--epochs、--max_length、--use_fgm、--use_ema等。
python main.py \ --pretrain_path ./pretrain_model \ --train_file ./train.json \ --eval_file ./evalA.json \ --schema_file ./schemas.json \ --batch_size 16 \ --lr 2e-5 \ --epochs 10 \ --max_length 128 \ --use_fgm True \ --use_ema True学习率 2e-5 是 BERT 微调的常规起点,太大容易灾难性遗忘,太小收敛慢。epochs设 10 左右,配合早停。如果开了 FGM,训练时间大约翻倍,因为每个 batch 要 forward 两次。显存不够就把batch_size降到 8,同时把lr稍微降到 1e-5。
4.2 predict.py 的推理流程与 submit_example_A.json 格式对齐
predict.py加载训练好的模型权重,对测试集做推理,输出格式对齐submit_example_A.json。推理流程是:读测试文件 → tokenizer 编码 → 模型 forward → 取 argmax 得到关系类别 → 按 schema 映射回关系名称 → 写入 JSON。
import json import torch from gpNet import RelationModel from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("./pretrain_model") model = RelationModel("./pretrain_model", num_relations=len(schemas)) model.load_state_dict(torch.load("./best_model.pt")) model.eval() with open("test.json", "r", encoding="utf-8") as f: test_data = json.load(f) results = [] for sample in test_data: encoding = tokenizer(sample["text"], return_tensors="pt", max_length=128, truncation=True) with torch.no_grad(): logits = model(encoding["input_ids"], encoding["attention_mask"], head_idx, tail_idx) pred = torch.argmax(logits, dim=-1).item() results.append({"text": sample["text"], "relation": id2rel[pred]}) with open("submit.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)注意head_idx和tail_idx在推理时也要从测试样本的实体标注里取,如果测试集没有实体标注,就需要先跑一个 NER 模型抽实体,再送进关系分类。源码里如果只做了关系分类,实体识别部分可能需要你自己补。
4.3 vote.py 的集成投票机制与多模型融合
vote.py从命名看是做投票集成的。常见做法是训练多个模型(不同随机种子或不同数据划分),推理时对每个样本的关系预测做多数投票。如果源码里只提供了一个模型,vote.py可能是预留的扩展接口。
from collections import Counter def majority_vote(predictions): # predictions: list of lists, 每个模型的预测结果 final = [] for i in range(len(predictions[0])): votes = [pred[i] for pred in predictions] counter = Counter(votes) final.append(counter.most_common(1)[0][0]) return final多模型融合通常能涨 1-2 个点,但推理成本翻倍。如果只跑单模型,这个文件可以忽略;如果想冲榜,建议至少训 3 个不同种子的模型做投票。
5. 避坑与排查:偏移错位、显存溢出、FGM 失效的常见问题
5.1 实体偏移与 token 边界不对齐导致标签错位
现象:训练 loss 正常下降,但验证集 F1 极低,预测结果和标注完全对不上。原因:dataloader.py里字符级偏移映射到 token 级时没有处理子词切分,实体起始位置落在了子词中间。解决:用return_offsets_mapping=True拿到每个 token 的字符区间,对每个实体找到覆盖其起始字符的第一个 token 作为head_idx,覆盖结束字符的最后一个 token 作为tail_idx。如果实体边界和 token 边界差一个字符,宁可扩展不要截断。
5.2 batch_size 过大导致 CUDA OOM 的显存排查
现象:训练启动后报CUDA out of memory,或者跑几个 batch 后突然 OOM。原因:max_length设太大(比如 512)加上batch_size16,BERT base 模型在 8G 卡上直接爆。解决:先把batch_size降到 8,max_length降到 128,用torch.cuda.empty_cache()清理缓存。如果还不行,开梯度累积,accumulation_steps=4等效 batch_size 32。另外注意 FGM 会额外占一份梯度存储,显存紧张时先关 FGM 跑通再开。
5.3 FGM 扰动后未 restore 导致参数污染
现象:开了 FGM 后模型完全不收敛,loss 震荡或变成 NaN。原因:attack()之后没有调restore(),扰动后的参数被带进了下一步优化。解决:训练循环里严格按forward → backward → attack → forward → backward → restore → optimizer.step()的顺序,restore()必须在optimizer.step()之前。可以在restore()里加断言检查 backup 是否为空,防止重复调用。
5.4 EMA 的 decay 设置不当导致验证指标滞后
现象:训练 loss 持续下降,但 EMA 模型的验证指标比原始模型还差。原因:decay=0.999在训练步数少(比如几千步)时,shadow 参数还大量保留初始权重,相当于用了一个没训好的模型。解决:训练步数少于 1 万步时把decay降到 0.99 或 0.995;或者前几个 epoch 不启用 EMA,等模型初步收敛后再开始累积。验证时对比原始模型和 EMA 模型的指标,哪个好用哪个。
5.5 schemas.json 与训练数据关系类型不一致导致 KeyError
现象:main.py启动时报KeyError: 'xxx关系'。原因:train.json里出现了schemas.json中没有定义的关系类型,或者拼写不一致(比如「连接」和「连接关系」)。解决:写一个校验脚本,遍历训练集所有关系类型,和schemas.json做差集,把多出来的类型要么加进 schema,要么从训练集里剔除。这个检查我每次换数据集都会跑一遍,能省掉大量调试时间。
6. 进阶技巧:用 vote.py 做多模型投票与 F1 验证的完整闭环
训练跑通之后,真正决定能不能用的是验证环节。这份源码给了evalA.json和evalA2.json两个验证集,我一般会先用evalA.json调参,再用evalA2.json做最终验证,避免过拟合验证集。验证脚本的核心是逐样本比对预测关系和标注关系,算 precision、recall、F1。
def compute_f1(preds, golds): tp = sum(1 for p, g in zip(preds, golds) if p == g and g != "无关系") fp = sum(1 for p, g in zip(preds, golds) if p != g and p != "无关系") fn = sum(1 for p, g in zip(preds, golds) if p != g and g != "无关系") precision = tp / (tp + fp) if tp + fp > 0 else 0 recall = tp / (tp + fn) if tp + fn > 0 else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0 return precision, recall, f1这个实现是简化版,实际评估时要注意「无关系」样本的处理——有些标注里没有关系的实体对不出现,有些显式标了「无关系」,两种情况的 fp/fn 计算方式不同。建议先统计验证集里正负样本比例,如果负样本占 90% 以上,F1 会被负样本主导,这时候要看正样本的召回率。
多模型投票的完整流程是:用不同随机种子训 3 个模型 → 每个模型对evalA2.json推理 →vote.py做多数投票 → 算投票后的 F1。我实测过,单模型 F1 在 78% 左右时,三模型投票能到 80% 上下,但训练和推理时间都是三倍。如果只是做 baseline 验证,单模型够了;如果要交付生产,投票的稳定性提升值得这个成本。
还有一个容易忽略的点:predict.py输出的submit.json要和submit_example_A.json的字段名、嵌套结构完全一致,否则提交会失败。我一般会写一个 diff 脚本,对比两个 JSON 的 key 集合,差一个就报错。从那以后我每次换数据集都强制走一遍「schema 校验 → 偏移对齐检查 → 单 batch 过拟合测试 → 全量训练 → 双验证集评估」这个流程,能挡掉九成以上的低级错误。希望帮到你。
本文还有配套的精品资源,点击获取