
简介这份资源面向自然语言处理方向的学生与开发者提供一套基于BERT-MRC机器阅读理解范式的中文命名实体识别完整实现适合用作课程设计、期末大作业或入门MRC式NER的实战参考。压缩包共23个文件以15个Python源码为主辅以4个txt说明与占位文件及4个pyc缓存整体约39KB结构涵盖数据加载、模型定义、预处理、训练与测试、评估指标如flat与嵌套span的F1计算以及实体抽取等模块并预留预训练模型与数据目录。已有301人学习下载说明该方案在同类课程项目中具备一定参考价值。读者可据此理解将NER转化为阅读理解问答的思路掌握MRC框架下的数据构造、tokenization处理、模型训练与评估流程并借助使用说明快速跑通全流程为后续调参、迁移到自有标注数据或撰写实验报告提供可复用的代码基础。1. 从一份 zip 说起bert-mrc 做中文 NER 到底解决了什么问题你拿到手的是一份名为python实现基于bert-mrc的中文命名实体识别源码全部数据.zip的工程包。先别急着解压跑train.py得先搞清楚它为什么不用传统的序列标注BIO方案而是绕一圈用机器阅读理解MRC的框架来做命名实体识别NER。传统 BIO 方案把 NER 当成逐 token 分类问题每个字打一个标签简单直接但有两个硬伤一是实体类型一多标签空间膨胀B-PER、I-PER、B-ORG、I-ORG这类标签互相干扰模型容易在边界上犯迷糊二是想加一个新实体类型就得重新标注数据、重训模型迁移成本高。MRC 方案换了个思路把「找出所有人物实体」变成一个问答任务——给模型一段文本和一个问题「文中的人物是谁」让模型像做阅读理解一样把答案片段抽出来。这样一来实体类型变成了自然语言问题加新类型只需要加一句问题模板不用动模型结构。这份源码包适合谁适合已经跑通过 BERT 微调、想找一个能落地、能扩展、带完整数据的中文 NER 实战项目的工程师。它不教你 Python 入门也不教你 vscode python 环境配置它假设你能自己把环境搭起来然后直接进入「怎么把 MRC 这套框架跑通、跑好」的阶段。接下来我会按「数据长什么样 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」的顺序把这份源码包背后的技术路线拆开讲清楚。2. 拆开 zip 先看数据MRC 格式的标注长什么样2.1 从 BIO 到 MRC标注格式的转换逻辑传统 NER 数据通常是{text: 张三在阿里巴巴工作, labels: [[B-PER, 0, 1], [B-ORG, 3, 7]]}这种形式每个实体用起始位置和类型表示。MRC 框架下你需要把这份数据转成「问题 上下文 答案」的三元组。具体来说对于每个实体类型生成一个问题模板比如人物类型对应「文中的人物是谁」然后遍历文本中所有该类型的实体把每个实体作为一个正样本答案就是实体在文本中的 span。同时还要构造负样本——即该问题在文中找不到答案的情况答案设为空。这一步是整条链路的地基转错了后面全白搭。常见做法是写一个转换脚本读原始 BIO 数据输出 MRC 格式的 JSON 行文件。下面是一个可复现的转换代码import json # 实体类型到问题模板的映射 TYPE_TO_QUERY { PER: 文中的人物是谁, ORG: 文中的组织机构是什么, LOC: 文中的地点在哪里, TIME: 文中的时间是什么 } def bio_to_mrc(text, entities): text: 原始文本 entities: [{type: PER, start: 0, end: 2}, ...] 返回: MRC 格式样本列表 samples [] for etype, query in TYPE_TO_QUERY.items(): # 正样本该类型的所有实体 matched [e for e in entities if e[type] etype] if matched: for ent in matched: samples.append({ context: text, query: query, start: ent[start], end: ent[end], answer: text[ent[start]:ent[end]] }) else: # 负样本该问题在文中无答案 samples.append({ context: text, query: query, start: -1, end: -1, answer: }) return samples # 示例 text 张三在阿里巴巴工作 entities [{type: PER, start: 0, end: 2}, {type: ORG, start: 3, end: 7}] for s in bio_to_mrc(text, entities): print(json.dumps(s, ensure_asciiFalse))这段代码的核心逻辑是对每种实体类型分别生成正负样本。正样本的start和end是实体在原文中的字符级索引负样本统一设为-1。参数上需要注意start和end是左闭右开区间text[start:end]正好取出实体文本。如果你的原始数据是 token 级标注需要先做 token 到字符的映射否则索引会错位。转换完成后建议统计一下正负样本比例通常负样本会远多于正样本后续训练时需要通过采样或损失加权来平衡。2.2 数据划分与标签对齐的检查清单转换完数据后别急着喂给模型。先做三件事第一检查start和end是否越界尤其是文本末尾的实体end不能超过len(text)第二检查答案文本是否和text[start:end]一致不一致说明索引算错了第三按 8:1:1 划分训练集、验证集、测试集划分时要以「原始文本」为单位不能把同一段文本的不同问题拆到不同集合否则验证集指标会虚高。下面是一个快速检查脚本def validate_mrc_samples(samples, text_keycontext): errors [] for i, s in enumerate(samples): ctx s[text_key] if s[start] -1: continue if s[start] 0 or s[end] len(ctx) or s[start] s[end]: errors.append((i, 索引越界, s)) elif ctx[s[start]:s[end]] ! s[answer]: errors.append((i, 答案不匹配, s)) return errors跑完这个检查如果errors为空说明数据格式没问题。如果有错优先排查原始 BIO 数据的索引是否从 0 开始、是否包含空格或标点导致的偏移。这一步花十分钟能省掉后面几小时的 debug。3. 模型结构BERT MRC 的头部怎么接3.1 从 BERT 输出到 span 预测两个线性层的设计BERT 的输出是每个 token 的 768 维向量base 版。MRC 框架下你需要预测答案的起始位置和结束位置。常见做法是在 BERT 输出之上接两个线性层一个用于预测 start 概率一个用于预测 end 概率。具体来说对于长度为L的输入序列BERT 输出[batch, L, 768]经过两个Linear(768, 1)后得到[batch, L, 1]再 squeeze 成[batch, L]最后对L维度做 softmax得到每个位置作为 start 或 end 的概率。损失函数用交叉熵start 和 end 分别算 loss 再相加。这里有个细节负样本的 start 和 end 都设为 0即 CLS 位置因为负样本没有答案模型需要学会在 CLS 位置输出高概率来表示「无答案」。import torch import torch.nn as nn from transformers import BertModel class BertForMRC(nn.Module): def __init__(self, bert_path, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.start_fc nn.Linear(768, 1) self.end_fc nn.Linear(768, 1) def forward(self, input_ids, attention_mask, token_type_ids): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output self.dropout(outputs.last_hidden_state) # [B, L, 768] start_logits self.start_fc(sequence_output).squeeze(-1) # [B, L] end_logits self.end_fc(sequence_output).squeeze(-1) # [B, L] return start_logits, end_logits参数说明dropout设为 0.1 是常见起点如果过拟合严重可以调到 0.2 或 0.3start_fc和end_fc是两个独立的线性层不共享权重因为起始和结束位置的语义不同。输入方面input_ids是 tokenizer 编码后的 IDattention_mask标记 padding 位置token_type_ids用于区分问题和上下文——通常问题在前、上下文在后用 0 和 1 区分。如果你的数据里问题和上下文拼接方式不同token_type_ids的构造也要相应调整。3.2 输入拼接问题在前还是上下文在前BERT 的输入格式是[CLS] 问题 [SEP] 上下文 [SEP]。这个顺序不是随便定的。问题在前模型先看到「要找什么」再带着这个目标去读上下文注意力机制会更容易聚焦到相关片段。反过来把上下文放前面模型读完一大段文本再看到问题效果通常会差一些。拼接时要注意max_length的设置一般 512 够用但如果你的文本很长需要做滑动窗口切分切分时要保证实体不被切断。下面是一个拼接和编码的示例from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_mrc_sample(context, query, answer_start, answer_end, max_len512): # 先编码问题和上下文再手动拼接避免 tokenizer 自动加 SEP 导致索引错位 query_ids tokenizer.encode(query, add_special_tokensFalse) context_ids tokenizer.encode(context, add_special_tokensFalse) # 构造 [CLS] query [SEP] context [SEP] input_ids [tokenizer.cls_token_id] query_ids [tokenizer.sep_token_id] context_ids [tokenizer.sep_token_id] token_type_ids [0] * (len(query_ids) 2) [1] * (len(context_ids) 1) # 计算答案在拼接后的位置偏移 offset len(query_ids) 2 # CLS query SEP if answer_start -1: start_pos, end_pos 0, 0 # 负样本指向 CLS else: start_pos answer_start offset end_pos answer_end offset # 截断 if len(input_ids) max_len: input_ids input_ids[:max_len] token_type_ids token_type_ids[:max_len] start_pos min(start_pos, max_len - 1) end_pos min(end_pos, max_len - 1) attention_mask [1] * len(input_ids) return { input_ids: input_ids, token_type_ids: token_type_ids, attention_mask: attention_mask, start_pos: start_pos, end_pos: end_pos }这段代码的关键在于offset的计算答案在原文中的字符索引要加上问题部分的长度和特殊 token 的数量才能映射到拼接后的 token 序列位置。如果直接用 tokenizer 的encode_plus并传token_type_ids容易在 SEP 位置出错所以我一般手动拼接。注意answer_start和answer_end是字符级索引而 BERT 用的是字级 token中文场景下基本一字一 token所以偏移量可以直接相加。如果你的 tokenizer 会把某些词切成 subword就需要用offset_mapping做更精细的对齐。4. 训练与调参让 MRC 模型真正收敛4.1 损失函数与负样本处理训练时start 和 end 的交叉熵损失直接相加。但负样本的处理是个关键点。前面提到负样本的 start 和 end 都指向 CLS 位置索引 0。如果负样本比例过高模型会倾向于把所有样本都预测为「无答案」导致召回率暴跌。常见做法是对负样本做降采样让正负比控制在 1:1 到 1:3 之间。另一种做法是给负样本的 loss 加一个权重比如 0.5降低它对梯度的影响。下面是一个带负样本权重的损失计算import torch.nn.functional as F def compute_loss(start_logits, end_logits, start_pos, end_pos, neg_weight0.5): start_logits: [B, L] start_pos: [B] 每个样本的起始位置 neg_weight: 负样本的损失权重 # 判断哪些是负样本start_pos 0 且 end_pos 0 is_neg (start_pos 0) (end_pos 0) weights torch.ones_like(start_pos, dtypetorch.float) weights[is_neg] neg_weight loss_start F.cross_entropy(start_logits, start_pos, reductionnone) loss_end F.cross_entropy(end_logits, end_pos, reductionnone) loss (loss_start * weights loss_end * weights).mean() return loss参数说明neg_weight设为 0.5 是经验值如果验证集上召回率低可以降到 0.3如果精确率低、误报多可以升到 0.8。reductionnone保留每个样本的 loss再乘权重后取平均。注意start_pos和end_pos是 LongTensor不能直接和 0 比较需要先转成相同类型。4.2 学习率与 batch size 的搭配BERT 微调的学习率通常设在 2e-5 到 5e-5 之间MRC 任务也不例外。batch size 方面如果显存够尽量用 16 或 32因为 MRC 的负样本多大 batch 能提供更稳定的梯度。如果显存不够用梯度累积模拟大 batch。下面是一个训练循环的骨架from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) start_pos batch[start_pos].to(device) end_pos batch[end_pos].to(device) start_logits, end_logits model(input_ids, attention_mask, token_type_ids) loss compute_loss(start_logits, end_logits, start_pos, end_pos) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad()关键参数lr3e-5是 BERT base 的常用起点如果 loss 震荡厉害降到 2e-5weight_decay0.01防止过拟合warmup设为总步数的 10%让学习率从 0 线性升到设定值避免一开始就大步更新破坏预训练权重clip_grad_norm_的max_norm1.0是防止梯度爆炸的保险。训练过程中每轮结束在验证集上算 F1如果连续两轮 F1 不升就提前停止。5. 避坑与排查MRC 做 NER 最容易翻车的五个地方5.1 现象验证集 F1 很高但推理时实体抽不出来原因验证集和训练集来自同一批文本模型记住了文本模式换一段新文本就失效。解决划分数据时确保训练集和验证集的文本不重叠最好来自不同来源。如果数据量少用交叉验证不要只做一次划分。5.2 现象负样本 loss 降得很快但正样本 loss 不降原因负样本太多模型学会了「全预测无答案」这个捷径。解决降采样负样本或者给负样本 loss 加权重参考 4.1 节。同时检查正样本的 start 和 end 是否在合理范围内如果正样本的索引映射错了模型根本学不到东西。5.3 现象预测出的实体边界总是多一个字或少一个字原因中文 tokenizer 对某些字符的处理不一致比如数字和英文混排时token 和字符不是一一对应。解决用offset_mapping做 token 到字符的精确映射或者在数据预处理阶段把文本统一转成纯中文去掉英文和数字的干扰。如果实体本身包含英文需要在 tokenizer 层面做特殊处理。5.4 现象训练到一半 loss 突然变成 NaN原因学习率太大或者梯度爆炸。解决降低学习率到 1e-5加梯度裁剪max_norm1.0检查输入数据中是否有空文本或超长文本导致 attention_mask 全零。另外AdamW的eps参数可以调到 1e-8避免除零。5.5 现象同一段文本不同问题模板的预测结果互相矛盾原因多个问题模板之间没有共享信息模型把每个问题当成独立任务。解决在训练时把同一段文本的所有问题样本放在同一个 batch 里让模型在 batch 内看到不同问题的上下文间接学到类型之间的关联。另一种做法是加一个辅助任务让模型预测实体类型但这就偏离了纯 MRC 的路线需要权衡。6. 验证与进阶怎么确认你的 MRC NER 真的能用训练完成后别只看 loss 曲线。拿测试集跑一遍按实体类型分别算精确率、召回率和 F1。下面是一个评估脚本的核心逻辑def evaluate(model, test_loader, id2type): model.eval() preds, golds [], [] with torch.no_grad(): for batch in test_loader: start_logits, end_logits model( batch[input_ids].to(device), batch[attention_mask].to(device), batch[token_type_ids].to(device) ) start_pred torch.argmax(start_logits, dim-1).cpu().numpy() end_pred torch.argmax(end_logits, dim-1).cpu().numpy() for i in range(len(start_pred)): if start_pred[i] 0 and end_pred[i] 0: preds.append((无答案, )) else: preds.append((有答案, (start_pred[i], end_pred[i]))) golds.append((batch[start_pos][i].item(), batch[end_pos][i].item())) # 按类型统计 F1此处省略具体计算 return preds, golds跑完评估后重点看两类错误一是边界错误即预测的 span 和真实 span 有重叠但不完全一致二是类型混淆即把人物预测成了组织。边界错误通常靠后处理修复比如对预测的 span 做扩展或收缩匹配到最近的实体边界。类型混淆则需要检查问题模板是否区分度不够比如「文中的人物是谁」和「文中的组织机构是什么」在某些语境下可能指向同一个词。进阶用法上可以尝试把 MRC 框架和传统 BIO 做融合用 MRC 做粗筛再用 BIO 模型做精细分类。或者用多轮问答的方式第一轮问「文中的人物是谁」第二轮问「文中还有没有其他人物」逐步抽完所有实体。我自己的习惯是每次加新实体类型时先跑一遍小样本测试确认问题模板不会和已有类型冲突再全量训练。这个习惯帮我省了好几次重新标注数据的麻烦。希望帮到你。本文还有配套的精品资源点击获取