人物关系抽取Pipeline实战:从标注到模型优化 📅 发布时间:2026/9/11 15:50:33 👁 浏览次数: 简介这是一份面向自然语言处理初学者与知识图谱入门者的关系抽取项目资源包聚焦人物关系抽取任务以流水线方式将分词、词性标注、实体识别、关系对抽取和关系分类拆解为可独立实现的模块。压缩包共14个文件体积96KB含4个Python脚本、4个数据集文本、4个XML配置文件、1个Excel关系表和1个工程模块文件脚本覆盖数据读取、预处理、模型训练与预测的完整流程数据按8:1:1划分为训练集、验证集和测试集。已有152人浏览学习属于轻量级入门资源适合想动手复现并优化人物关系抽取流程的读者。资源中提供测试集F1约0.6的基线结果并给出增加数据量、数据增强、模型优化、引入知识图谱等改进思路可帮助快速理解流水线各环节的实现细节与效果评估也方便进一步复现、调参和二次开发。1. 人物关系抽取为什么先选 Pipeline人物关系抽取这个任务搜出来的资料不算少但能跑通的完整管线其实不多。这个压缩包把“从原始文本到人物关系三元组”的整条链路放在了明面上数据预处理、模型训练、预测脚本、关系映射表齐全训练/验证/测试按 8:1:1 切分测试集 F 值约 0.6。F1 不高但这份资源的价值恰恰在这里——它暴露了 Pipeline 方式最典型的性能瓶颈也方便你顺着代码逐个环节做错误分析。适合两类人一类是刚接触关系抽取、想快速跑通一个端到端流程的学习者另一类是已经在做知识图谱构建、想对比 Pipeline 与联合抽取差异的工程师。对于后者0.6 的 F1 本身就是一个很好的优化起点。2. 数据准备从 xlsx 到模型可读的标注序列2.1 文件结构里藏着的数据流先看relation_extract_people-master的目录组织数据和代码的边界很清晰data/ relation2id.txt # 关系名称到数字 ID 的映射 人物关系表.xlsx # 原始关系定义由 read_xlsx_file.py 读取 train.txt / val.txt / test.txt read_xlsx_file.py pre_process.py # 原始文本 - BIO 标注序列 model_train.py # 训练 NER 关系分类器 predict.py # 新文本 - 三元组relation2id.txt是整个标注体系的“字典”每一行格式是关系名TAB数字id。read_xlsx_file.py的作用就是把人物关系表.xlsx里的关系定义读出来转成这个 txt给后续训练脚本统一引用。这种做法的好处是业务侧新增关系类型时只需要改 Excel不需要动代码里的枚举。2.2 关系表读取脚本的典型写法read_xlsx_file.py的核心逻辑不复杂用 pandas 读 Excel把两列变成 dict 再落盘import pandas as pd def read_relation_table(xlsx_path: str, output_txt_path: str) - dict: df pd.read_excel(xlsx_path, header0, dtypestr) # 约定表头第一列是关系名第二列是 ID relation2id { row[relation]: int(row[id]) for _, row in df.iterrows() } with open(output_txt_path, w, encodingutf-8) as f: for rel, rid in relation2id.items(): f.write(f{rel}\t{rid}\n) return relation2id if __name__ __main__: rel_map read_relation_table(data/人物关系表.xlsx, data/relation2id.txt) print(floaded {len(rel_map)} relations)这里有个细节dtypestr必须写否则 pandas 会把关系 ID 读成 int64写入 txt 时如果混入空值会出现nan后续模型训练时类型转换会直接报错。我一般还会在写入前加一行df df.dropna(subset[relation, id])做兜底xlsx 里多一个空行不至于让整条管线崩掉。2.3 文本数据长什么样train.txt/val.txt/test.txt是模型真正消费的数据。常见格式是每行一个句子实体和关系以标注形式内嵌。这个项目使用的是一种比较轻量的方案句子按空格分词每个 token 后面跟 BIO 标签实体边界和关系类别都由标签体系承载。以“张三 是 李四 的 父亲”为例可能的标注结果如下token标签张三B-PER是O李四B-PER的O父亲O注意这里父亲是关系触发词但不在 NER 阶段打标签——NER 只负责识别实体关系类型在后续的实体对分类阶段决定。这种解耦正是 Pipeline 的核心特征每一层只做一件事训练时可以单独调参。2.4 pre_process.py 的清洗逻辑pre_process.py负责把原始语料转成上述格式。它的工作有三件事过滤过短句子、给实体词打标签、统一标点符号。过滤逻辑一般是这样def filter_and_label(sentence: str, entities: list) - str | None: if len(sentence) 10: # 过短句子没有上下文样本价值低 return None tokens list(sentence) # 中文按字切分 labels [O] * len(tokens) for name, start, end, _ in entities: labels[start] B-PER for i in range(start 1, end): labels[i] I-PER return .join(f{t}/{l} for t, l in zip(tokens, labels))这个函数里有几个工程点值得注意按字切分是因为中文人名不需要词表字级别 BIO 在领域数据上更稳实体坐标必须和句子切分前的偏移量一致否则标签错位是训练时最隐蔽的 bug。我在类似项目里通常会在预处理后加一步自检统计 B-PER 和 I-PER 的比值正常应接近 1:1.5 到 1:2偏离太多说明标注对齐出了问题。3. 模型训练NER 与关系分类的解耦实现3.1 为什么拆成两个模型Pipeline 方式的本质是任务分解先做命名实体识别再做实体间关系分类。两个模型独立训练数据结构也独立。这样做最直接的好处是调试成本低——预测结果不对时你能立刻判断是 NER 没找到实体还是关系分类分错了类别。代价是误差会从第一阶段向第二阶段传播实体识别漏掉一个名字关系抽取阶段根本不会生成这个实体对同样的实体边界多套了一个字关系分类器的输入就已经脏了。F1 卡在 0.6很大程度上就是这种级联误差的下限。3.2 NER 部分用序列标注model_train.py里 NER 的常见选择是 BiLSTM-CRF 或 BERT 加线性分类层。这个项目没有指定模型框架但按结构推断用的是字级别输入加 BiLSTM-CRF 的概率最大——CRF 保证标签序列的合法性B 后面不能直接跟 I在人物实体边界不复杂时效果足够。训练循环的核心逻辑可以简化成import torch def train_ner(model, train_loader, optimizer, epochs10): model.train() for epoch in range(epochs): total_loss 0.0 for batch in train_loader: # batch: (input_ids, attention_mask, label_ids) input_ids batch[input_ids] attention_mask batch[attention_mask] label_ids batch[label_ids] optimizer.zero_grad() loss, _ model( input_idsinput_ids, attention_maskattention_mask, labelslabel_ids, ) loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fepoch {epoch 1}, loss {avg_loss:.4f})model是 HuggingFace 的BertForTokenClassification标签数等于len(id2label)。注意attention_mask必须传入否则填充的 [PAD] 位置也会参与 loss 计算导致模型学到一堆噪声。训练时按 8:1:1 划分好数据后train.txt只用于更新权重val.txt用于每轮结束后的早停判断——验证集 loss 连续 3 轮不降就降低学习率这种策略在数据量不大的场景下比固定训练轮数更稳。3.3 关系分类部分用实体对特征关系分类器的输入是“头实体 尾实体 句子”。这个项目里会在 NER 预测结果上做实体两两配对把所有可能的人物实体对都送进分类器打标签。如果实体识别准确这个阶段就是一个多分类问题。实现时有一种工程上很顺手的做法模板化句子输入。def build_relation_input(sentence: str, head: str, tail: str) - str: # 常见做法把实体原样拼在句子前面让模型显式感知实体边界 return f{head}[SEP]{tail}[SEP]{sentence}送入 BERT 后取[CLS]位置的输出接一个Linear(len(relations))。简单有效也比拼接位置向量好解释。训练关系分类器时的关键参数如下参数推荐值说明max_len128超过 128 的句子直接截断关系触发词通常在句中batch_size16显存不够时优先降到 8而不是缩短 max_lenlearning_rate2e-5用预训练模型时超过这个值容易灾难性遗忘epoch5-10配合早停观察验证集 F1 峰值关系类别数与 relation2id.txt 一致新增类别要同步更新 map否则 label 越界3.4 训练时容易被忽略的样本均衡问题人物关系抽取的真实场景里类别分布极不均衡“合作”关系可能占 60%一些冷门亲属关系只有几十条样本。模型很容易学成“全预测合作”F1 虚高但对知识图谱没有意义。如果发现测试集 F1 只有 0.6先看混淆矩阵里预测为合作的占比——高于 50% 基本就是类别失衡。应对手段按成本从低到高排序一是分类器输出层温度调节对负样本多的类别放大 logits二是对冷门类别做简单的过采样复制样本但改变同义词替换的位置三是改用weighted cross entropy给样本少的类别更高 loss 权重。用 PyTorch 实现时只需要多传一个class_weight参数from torch.nn import CrossEntropyLoss def get_weighted_loss(label_freq: dict, device: str): total sum(label_freq.values()) weights [total / label_freq[i] for i in range(len(label_freq))] return CrossEntropyLoss(weighttorch.tensor(weights, devicedevice))权重归一化可以不做softmax 对相对大小敏感只要比例关系正确效果就成立。4. 预测链路从单条文本到关系三元组4.1 predict.py 的完整推理流程predict.py做的事情就是把训练好的两个模型串起来输入一段文本 → NER 抽人名 → 两两配对 → 关系分类 → 过滤低置信度 → 输出三元组。伪代码流程如下import torch def predict(sentence: str, ner_model, relation_model, relation_id2name: dict) - list: # 第一步NER 识别所有人名实体 tokens, ner_tags ner_model.predict(sentence) people extract_entities(tokens, ner_tags) # 返回 [(name, start, end), ...] # 第二步实体两两配对 triples [] for i in range(len(people)): for j in range(len(people)): if i j: continue head people[i][0] tail people[j][0] rel_logits relation_model.predict(head, tail, sentence) rel_id int(torch.argmax(rel_logits)) prob float(torch.softmax(rel_logits, dim-1)[rel_id]) if prob 0.6: # 阈值过滤避免输出大量 O continue rel_name relation_id2name[rel_id] triples.append((head, rel_name, tail)) return triples这段代码有几个工程点需要解释。extract_entities根据 NER 标签做实体还原B-PER 开头后面连续 I-PER 合并成一个名字relation_id2name在启动时从relation2id.txt读入和训练时完全一致。阈值 0.6 不是拍脑袋写的——在验证集上画 PR 曲线选精确率和召回率交叉位置对应的概率值一次找不准就在 0.5 到 0.7 之间二分尝试。4.2 NER 输出不干净时怎么补救Pipeline 方式的现实问题是 NER 会有漏报。预测脚本里我一般会再加一层兜底对没有任何实体输出的句子用预训练的分词工具再跑一次人名匹配匹配到的目标拼进候选项但单独打一个LOW_CONFIDENCE标记。关系分类阶段对这些候选实体对使用更高的阈值避免把噪声输入模型。这类策略不需要重新训练模型只改 predict 脚本是性价比很高的修复手段。候选实体对数量也需要控制。一个句子超过 8 个人名时两两配对会产生 28 个关系对分类器全部跑一遍耗时明显增加而且大量实体对之间根本没有关系。常见做法是按实体在句子中的相对位置加窗过滤只保留距离不超过 30 个字符的实体对。人物关系的触发词大多在中间位置距离过远的关系对即使是真关系单句上下文也不足以支撑分类器做出可靠判断。4.3 结果输出与知识图谱对接预测结果输出为一行一个三元组方便直接灌入知识图谱。常见做法是写成 TSVpython predict.py --input data/sample.txt --output result.tsv \ --ner_model outputs/ner_model --rel_model outputs/rel_model \ --threshold 0.6输出示例张三 父子 张小明 李四 合作 王五注意方向性人物关系三元组是带方向的父子和子父在知识图谱里语义不同。predict.py处理的是“头实体 → 关系 → 尾实体”的有向结构所以训练数据里必须把关系方向标注明确例如张三/李四/父亲和李四/张三/儿子是两个独立样本不能合并。5. 把 F1 从 0.6 往上推错误传播定位法5.1 先分清错误发生在哪一级测试集 F1 0.6 时直接调模型参数往往无效。我通常先做一步错误传播分析把测试集真实 NER 结果标出来分别计算“只评估 NER”和“NER 正确但关系分类错误”两个口径的指标。用脚本实现只需要把错误样本按阶段分类打印python eval_pipeline.py \ --gold data/test.txt \ --pred outputs/predict_result.tsv \ --error-analysis both输出一张错误分布表常见拆解如下错误类别占比典型原因优化方向NER 漏人约 30%文本中人名过短或生僻增加训练语料中的人名多样性NER 边界错误约 10%“王小明”被拆成“王”和“小明”改用 CRF 层强化边界约束关系分类混淆约 40%“朋友”和“同事”语义接近增加两个类别各自的训练样本实体对无关系却输出关系约 20%负例不足全预测 O 的句子加入训练如果 NER 错误占比偏高先修数据再调模型如果关系分类是主要瓶颈重心放在样本均衡和阈值调整上。这个分析和增量修改的循环通常比盲目换 BERT 变体更有效。5.2 一个实用的验证技巧关系阈值回归测试关系分类器的输出分布会在训练过程中漂移。我习惯把每个 epoch 结束时的验证集预测概率分布缓存下来画一张直方图——如果概率值集中分布在 0.4 到 0.7 之间说明分类器对多数关系信心不足单纯调阈值帮不了多少更该考虑样本量问题如果概率分布在两端说明分类器有信心但阈值选得不对此时把阈值从 0.5 提到 0.7 通常能显著提升精确率同时 F1 下降不超过 1 个点。这个回归测试脚本不需要额外依赖训练时顺手把每个 batch 的预测概率记录到一个 json 文件里换阈值时直接读取重新计算不需要重跑推理检查边界情况时省下不少时间。5.3 不改模型也能提分的后处理对称关系增强人物关系里有一类是对称的比如“合作”、“同事”。模型如果预测出A 合作 B但漏掉了B 合作 A不用重新训练在后处理时补上即可。做法是维护一个对称关系集合预测结果里只保留置信度更高的那个方向作为候选SYMMETRIC_RELATIONS {合作, 同事, 朋友} def symmetry_postprocess(triples: list) - list: seen {} for head, rel, tail in triples: if rel not in SYMMETRIC_RELATIONS: continue # 同一条关系只保留置信度高的一侧 pair (head, tail) if head tail else (tail, head) prev_prob seen.get((pair, rel), 0.0) if prev_prob 0.0: # 占位实际比较概率时需要从输入传入 pass return triples实际使用时需要把分类器概率传入symmetry_postprocess只对对称关系类别的头部少于尾部的预测结果做镜像补充。这类关系在人物关系表中很常见试一下可能直接涨 2 到 3 个 F1 点。最后提醒一句relation2id.txt里如果存在其他或未知类别阈值过滤和对称增强都要显式跳过这些类别否则会把无意义的关系写进知识图谱。本文还有配套的精品资源点击获取