简介:面向自然语言处理初学者与进阶开发者的中文命名实体识别实战资源,围绕在Python环境下调用预训练BERT模型识别中文文本中人名、地名、机构名等实体展开,帮助读者打通从模型加载、数据预处理到训练评估的完整链路。压缩包共9个文件,约3.72MB,以txt语料与词表、py训练脚本、pl评估脚本及md说明文档为主,另附示意图,覆盖训练集、验证集、测试集与BERT_NER主程序等模块,目录结构清晰便于按流程查阅。已有3346人学习下载,具备一定参考热度。内容涉及BERT双向编码器结构与MLM、NSP预训练任务,IOB标注体系与Precision、Recall、F1评估指标,以及Hugging Face Transformers库的模型实例化、标记化、特殊令牌添加与序列填充等操作,并讨论中文分词与字符编码处理要点,配合优化器选择与学习率调度设置,可帮助读者理解中文NER任务的实现思路与调优方向。
1. 中文 NER 落地:为什么我最终选了 BERT 而不是规则和 CRF
去年接了个中文实体抽取的活,需求方丢过来一批客服对话和商品标题,要抽人名、地名、机构名,还要抽商品型号。我第一反应是上正则加词典,两天写完,召回率惨不忍睹——商品型号千奇百怪,机构名缩写满天飞,词典永远补不完。后来换 CRF 加特征工程,调特征调到怀疑人生,换一个业务场景就得重来一遍。最后我把目光落到预训练语言模型上,用 BERT 做序列标注,才算把这个坑填平。
这份资源就是围绕「Python + BERT 做中文 NER」展开的一套可跑通的工程代码。它解决的不是「BERT 是什么」这种科普问题,而是把中文 NER 从数据标注格式、Tokenizer 对齐、模型微调、到推理抽取的整条链路串起来,让拿到源码的人能直接在自己的语料上跑起来。适合两类人:一是刚接触 NLP 工程、想找一个完整序列标注项目照着跑的新手;二是做过规则或传统模型、想切到预训练方案、需要看清标签对齐和推理细节的熟手。自然语言处理里 NER 是最经典的 token 级任务,把它吃透,后面做关系抽取、事件抽取都是同一套底子。
2. 项目结构与技术选型:一套序列标注工程该长什么样
2.1 目录骨架与各模块职责
拿到源码先别急着跑,先把目录扫一遍,心里有个地图。这类中文 NER 工程,常见做法是拆成数据、模型、训练、推理、配置五块,目录大致如下:
bert-chinese-ner/ ├── configs/ │ └── ner_config.json # 超参、路径、标签体系配置 ├── data/ │ ├── train.txt # 训练集,BIO 或 BIOES 标注 │ ├── dev.txt # 验证集 │ └── labels.txt # 标签列表,顺序即 id 映射 ├── src/ │ ├── dataset.py # 数据读取、标签对齐、Dataset 封装 │ ├── model.py # BERT + 分类头 │ ├── train.py # 训练循环、评估、保存 │ ├── predict.py # 单条/批量推理与实体还原 │ └── utils.py # 日志、指标计算、种子设置 ├── pretrained/ │ └── bert-base-chinese/ # 本地预训练权重目录 └── requirements.txt这个结构不是随便摆的。dataset.py是整个项目最容易翻车的地方,因为 BERT 的 Tokenizer 会把一个汉字切成一个 token,但遇到英文、数字、标点时会做子词切分,标签必须跟着 token 走,不能跟着原始字符走。model.py很薄,本质就是 BERT 编码器接一个线性层做 token 分类。train.py负责把前两者串起来,predict.py负责把 id 序列还原成实体字符串。配置单独抽出来,是为了换数据集时不用改代码。
2.2 技术栈与依赖说明
技术栈很克制:PyTorch 做训练框架,HuggingFace 的 transformers 加载 BERT,其余就是 numpy、tqdm、scikit-learn 算指标。requirements.txt里通常长这样:
torch>=1.10 transformers>=4.20 numpy tqdm scikit-learn选 PyTorch 而不是 TensorFlow,是因为 transformers 生态里 PyTorch 的示例和社区答案更多,出问题好搜。选bert-base-chinese而不是更大的模型,是因为中文 NER 这种任务,base 规模在多数业务语料上已经够用,显存占用也友好,单卡 8G 就能微调。这里有个血泪经验:不要一上来就上大模型,先把 base 跑通、把标签对齐搞对,再考虑换模型,否则你连问题是出在数据还是模型上都分不清。
2.3 标签体系:BIO 还是 BIOES
标签体系直接决定后面代码怎么写。BIO 只有 B、I、O 三类前缀,BIOES 多了 E(实体结束)和 S(单字实体)。BIOES 对实体边界的约束更强,模型学起来更稳,但标注成本略高。这份工程默认按 BIO 组织,labels.txt内容类似:
O B-PER I-PER B-LOC I-LOC B-ORG I-ORG顺序就是 id 映射,第 0 行是 O,第 1 行是 B-PER,以此类推。注意这个顺序必须和训练、推理完全一致,一旦错位,模型训出来的结果会莫名其妙地全错,而且不报错,这是最阴的坑之一。我一般会在dataset.py里加一句断言,校验标签文件行数和模型输出维度是否一致。
3. 数据管道拆解:标签对齐是整条链路的地基
3.1 从原始标注到 token 级标签
原始语料通常是「字符 + 标签」逐行对齐的格式,类似:
张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC但 BERT 的 Tokenizer 输入的是 token id 序列,一个中文字符通常对应一个 token,可一旦遇到「iPhone13」这种,会被切成多个子词。所以核心逻辑是:先对原始字符序列做 tokenize,拿到offset_mapping,再把字符级标签映射到 token 级。常见做法是只给每个 token 的第一个子词打标签,其余子词标成 -100(PyTorch 的 ignore_index),这样损失函数会跳过它们。
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("pretrained/bert-base-chinese") def align_labels(chars, char_labels, max_len=128): # chars: 字符列表, char_labels: 与 chars 等长的标签 id 列表 encoding = tokenizer( chars, is_split_into_words=True, truncation=True, max_length=max_len, return_offsets_mapping=True, ) word_ids = encoding.word_ids() # 每个 token 对应第几个字符 labels = [] prev_word_id = None for word_id in word_ids: if word_id is None: labels.append(-100) # [CLS] [SEP] [PAD] 不参与损失 elif word_id != prev_word_id: labels.append(char_labels[word_id]) # 子词首 token 打真实标签 else: labels.append(-100) # 同一字符的后续子词忽略 prev_word_id = word_id encoding["labels"] = labels return encoding这段代码的关键在word_ids()。它告诉你每个 token 来自原始输入的第几个「词」,这里我们把每个汉字当成一个词传进去(is_split_into_words=True),所以 word_id 就是字符下标。prev_word_id用来判断当前 token 是不是某个字符的第一个子词,只有第一个子词继承标签,后续子词一律 -100。参数max_len控制截断长度,中文 NER 句子一般不长,128 够用,长文本要按实体边界切,别从实体中间截断,否则会造出半个实体。
3.2 Dataset 封装与动态 padding
单条样本处理好之后,要包成 PyTorch 的 Dataset,再交给 DataLoader 做批处理。这里有个细节:不同句子长度不同,padding 到固定长度会浪费算力,常见做法是用DataCollatorForTokenClassification做动态 padding,一个 batch 内对齐到该 batch 最长句即可。
import torch from torch.utils.data import Dataset from transformers import DataCollatorForTokenClassification class NerDataset(Dataset): def __init__(self, file_path, tokenizer, label2id, max_len=128): self.samples = [] self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len self._load(file_path) def _load(self, path): chars, labels = [], [] with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: # 空行代表一个句子结束 if chars: self.samples.append((chars, labels)) chars, labels = [], [] continue char, tag = line.split() chars.append(char) labels.append(self.label2id[tag]) if chars: self.samples.append((chars, labels)) def __len__(self): return len(self.samples) def __getitem__(self, idx): chars, labels = self.samples[idx] return align_labels(chars, labels, self.max_len) collator = DataCollatorForTokenClassification(tokenizer)_load按空行切句,这是 CoNLL 格式的惯例。__getitem__直接复用上一节的align_labels。collator 会自动把labels也 padding 成 -100,保证 batch 内张量形状一致。注意label2id必须从labels.txt读出来构建,不要手写,手写迟早和文件对不上。
3.3 数据质量检查:先看分布再训练
数据管道写完,别急着开训。我一般会先跑一个统计脚本,看每个标签的样本数、实体长度分布、句子长度分布。如果某个实体类型只有几十条,模型基本学不会,得先补数据或做增强。如果句子长度普遍超过 128,就得调max_len或者做长文本切分。这一步花十分钟,能省后面几小时的瞎调参。
4. 模型与训练:BERT 接分类头的完整实现
4.1 模型定义:编码器加 token 分类头
模型本身不复杂,BERT 输出每个 token 的隐向量,接一个线性层映射到标签空间。用BertForTokenClassification一行就能搞定,但自己写一遍能看清结构,方便后面改。
import torch.nn as nn from transformers import BertModel class BertNer(nn.Module): def __init__(self, bert_path, num_labels, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) hidden = self.bert.config.hidden_size # base 模型是 768 self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(hidden, num_labels) def forward(self, input_ids, attention_mask, token_type_ids=None, labels=None): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) sequence_output = outputs.last_hidden_state # [B, L, 768] logits = self.classifier(self.dropout(sequence_output)) # [B, L, num_labels] 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 loss, logitsnum_labels必须等于labels.txt的行数。ignore_index=-100让 padding 和子词位置不参与损失。dropout放在分类头前,是防过拟合的常规操作,语料小的时候可以调到 0.2 甚至 0.3。token_type_ids对单句任务其实可以不给,但保留着兼容性更好。
4.2 训练循环:学习率、评估与保存
BERT 微调的学习率不能大,常见是 2e-5 到 5e-5,太大直接训崩。优化器用 AdamW,加一点 warmup。评估不能只看 loss,要看实体级别的 F1,因为 token 级准确率会被大量的 O 标签拉高,看着 99% 其实实体一个没抽对。
from transformers import AdamW, get_linear_schedule_with_warmup from seqeval.metrics import f1_score, classification_report def train_one_epoch(model, loader, optimizer, scheduler, device): model.train() total_loss = 0 for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} loss, _ = model(**batch) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() return total_loss / len(loader) def evaluate(model, loader, id2label, device): model.eval() preds, trues = [], [] with torch.no_grad(): for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} _, logits = model(**batch) pred_ids = logits.argmax(-1).cpu().numpy() true_ids = batch["labels"].cpu().numpy() for p, t in zip(pred_ids, true_ids): pred_seq, true_seq = [], [] for pi, ti in zip(p, t): if ti == -100: continue pred_seq.append(id2label[pi]) true_seq.append(id2label[ti]) preds.append(pred_seq) trues.append(true_seq) return f1_score(trues, preds)clip_grad_norm_防梯度爆炸,scheduler.step()每个 batch 走一次。评估时把 -100 的位置跳过,只比较有效 token。seqeval的f1_score是按实体级别算的,比 token 级靠谱得多。训练脚本里一般会保存 dev F1 最高的那个 checkpoint,而不是最后一个 epoch,因为 BERT 微调后期容易过拟合。
4.3 关键超参怎么定
| 参数 | 常用值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 ~ 5e-5 | 太大训崩,太小收敛慢 |
| batch_size | 16 / 32 | 受显存限制,8G 卡用 16 |
| epochs | 3 ~ 5 | 小语料 3 轮就够,多了过拟合 |
| max_len | 128 | 按语料长度分布定 |
| warmup_ratio | 0.1 | 前 10% 步数线性升温 |
| dropout | 0.1 ~ 0.3 | 语料小往大调 |
这套参数不是拍脑袋,是这类任务里比较稳的起点。真正要调的是数据,不是这些数字。我见过太多人把学习率从 2e-5 调到 3e-5 再调回来,其实问题出在标签错位上。
5. 推理与避坑:从 id 序列还原实体,以及那些让我熬夜的坑
5.1 推理与实体还原
训练完拿到 checkpoint,推理要做两件事:把文本转成 token,把模型输出的 id 序列还原成实体字符串。还原逻辑要处理 BIO 的合法性,比如出现 I-PER 前面没有 B-PER 的情况,要容错。
def extract_entities(text, model, tokenizer, id2label, device, max_len=128): model.eval() encoding = tokenizer( list(text), is_split_into_words=True, return_tensors="pt", truncation=True, max_length=max_len, ) word_ids = encoding.word_ids() with torch.no_grad(): _, logits = model( input_ids=encoding["input_ids"].to(device), attention_mask=encoding["attention_mask"].to(device), ) pred_ids = logits.argmax(-1)[0].cpu().numpy() entities, current = [], None for idx, word_id in enumerate(word_ids): if word_id is None: continue label = id2label[pred_ids[idx]] if label.startswith("B-"): if current: entities.append(current) current = {"type": label[2:], "start": word_id, "end": word_id + 1} elif label.startswith("I-") and current and label[2:] == current["type"]: current["end"] = word_id + 1 else: if current: entities.append(current) current = None if current: entities.append(current) return [(e["type"], text[e["start"]:e["end"]]) for e in entities]word_ids()在这里再次派上用场,把 token 位置映射回字符位置,才能正确切片。I-标签只有在类型和当前实体一致时才续接,否则视为新实体起点或丢弃,这是 BIO 解码的容错处理。返回的是(实体类型, 实体文本)列表,业务方直接能用。
5.2 常见问题排查:五个让我熬夜的坑
现象一:训练 loss 正常下降,但 dev F1 一直是 0。原因多半是标签 id 映射错位,labels.txt的顺序和模型输出维度对不上,或者label2id构建时用了字典无序遍历。解决:显式按文件行序构建映射,训练前打印前几条样本的labels和原始标签对照。
现象二:实体边界总是多一个字或少一个字。原因是子词对齐没处理好,align_labels里给后续子词打了真实标签而不是 -100,导致模型学到错误的边界。解决:确认prev_word_id逻辑,打印一条含英文数字的样本,逐 token 核对标签。
现象三:推理时实体位置整体偏移。原因是推理用了tokenizer(text)而不是tokenizer(list(text), is_split_into_words=True),前者会做额外的规范化,字符下标和原始文本对不上。解决:训练和推理必须用同一种 tokenize 方式,这点没有商量余地。
现象四:显存溢出,batch_size 调到 4 还是 OOM。原因是max_len设太大,或者没开梯度累积硬扛大 batch。解决:先统计语料长度分布,把max_len设到覆盖 95% 样本即可,剩余长样本单独切分处理。
现象五:换了一份新语料,F1 断崖下跌。原因是新语料的实体类型和旧标签体系不一致,或者标注规范不同(比如旧数据机构名含「公司」二字,新数据不含)。解决:换数据先对齐标签体系和标注规范,别指望模型自己适应。
提示:每次改完数据管道,先跑 20 条样本的「训练-推理」闭环,肉眼核对实体抽取结果,再开全量训练。这个习惯帮我省了无数次重训。
6. 二次开发:换领域、加实体类型和蒸馏提速的改造思路
拿到这套代码,真正的工作量在改造,不在跑通。第一个常见需求是换领域,比如从通用新闻切到医疗或金融。这时候不要直接拿通用 BERT 硬微调,先看领域语料量,量少就用通用bert-base-chinese继续微调,量多可以考虑领域预训练模型做初始化。改造点在configs/ner_config.json里换bert_path,同时把labels.txt换成新领域的标签体系,dataset.py不用动,因为它是按文件读标签的。
第二个需求是加实体类型。比如原来只有 PER/LOC/ORG,现在要加「商品」和「时间」。做法是在labels.txt里追加B-PROD、I-PROD、B-TIME、I-TIME,然后重新训练。注意不要只加标签不补数据,新类型没有样本,模型永远预测不出来。我一般会先补至少几百条含新类型的标注,再开训。改造后验证方法很直接:构造一批只含新类型的测试句,看召回,别只看整体 F1,整体 F1 会被老类型稀释。
第三个需求是推理提速。BERT base 在 CPU 上单条推理几十毫秒,批量上来就顶不住。常见做法是导出 ONNX 或用动态量化,把线性层和 embedding 量化成 int8,速度能提两三倍,精度掉一点点。改造点在predict.py外面包一层,训练代码不动。如果业务对延迟极敏感,还可以考虑把 BERT 蒸馏到更小的模型,但那是另一个工程了,先把当前这套跑稳再说。
| 改造方向 | 改动文件 | 验证方式 |
|---|---|---|
| 换领域 | configs、labels.txt | 新领域测试集实体 F1 |
| 加实体类型 | labels.txt、补数据 | 新类型单独召回率 |
| 推理提速 | predict.py 外层 | 延迟与精度对比 |
| 长文本 | dataset.py 切分逻辑 | 跨切分实体完整性 |
最后说个我自己的习惯。每次改完标签体系或数据管道,我都会强制走一遍「20 条样本肉眼核对」的流程,不管多急都不跳过。有次赶进度跳过了,结果标签错位训了一晚上,第二天发现 F1 是 0,那种感觉比加班还难受。从那以后我每次动数据相关代码都先跑小闭环,确认实体抽对了再开全量。这套代码不大,但把标签对齐、训练评估、推理还原这几块吃透,中文 NER 这条线基本就通了,后面接关系抽取也是顺手的事。希望帮到你。
本文还有配套的精品资源,点击获取