简介:面向Python开发者与自然语言处理入门者,该资源聚焦基于预训练BERT模型完成中文命名实体识别,覆盖模型加载、中文数据预处理、训练与评估完整流程,可快速搭建中文NER实验环境。压缩包共9个文件,整体约三点七二兆字节,包含模型构建与指标计算的脚本、训练验证测试文本及词汇表、评估工具和说明文档,结构清晰便于一步步对照学习。已有三千三百四十二人浏览学习,适合希望通过Hugging Face Transformers库理解BERT双向编码器、IOB标注体系及精确率召回率F1评估方法的读者。通过该资源可掌握从原始中文文本到实体预测的完整处理链路,包括标记化、特殊令牌添加、序列填充等关键环节,并获得可复用的代码框架与调参思路,为真实场景中的中文信息抽取奠定坚实基础。
1. 用BERT做中文NER:从跑通到能用的差距在哪
做中文命名实体识别(NER)的开发,大多会经历两种翻车:先是写规则写到怀疑人生,新语料一来就漏;然后换了预训练语言模型,发现跑通不难,难在数据对齐、显存爆炸、指标虚高。Python 开发里用 BERT 做中文 NER,确实能给这类问题一个相对稳定的答案——把实体抽取从“手写词典”变成“序列标注微调”。这篇文章不会停留在“BERT 很强大”这种正确但没用的结论上,而是把模型选型、数据格式、对齐逻辑、训练参数和踩坑点串起来,让你照着做就能把一份中文语料变成可用的实体抽取模型。适合有 Python 基础、准备接手自然语言处理相关需求的开发者。
2. 选型与前置:为什么是BERT、框架怎么选、模型下哪一个
2.1 BERT给中文NER带来什么:从规则、静态词向量到上下文表示
命名实体识别本质上是一个序列标注任务:给一句话里的每个字或词打上一个标签,例如 B-PER 表示人名开头、I-ORG 表示组织名内部。早期做法是词典配合正则,把候选实体先捞出来再过滤。这种方式在封闭场景里有效,但换个数据源就崩,因为中文实体没有固定边界,也几乎没有完全列得完的名单。
后来有人用 Word2Vec 这类静态词向量,把词表里的每个词固定成一个向量,再丢进 BiLSTM 或 CRF 里做序列标注。静态词向量的问题是“一词一义”,像“苹果”在水果和公司两种语境下拿到的向量是同一个,模型只能靠上下文硬猜。BERT 的核心差异在于,它用双向 Transformer 把整句信息融合进每个 token 的表示,同一个“苹果”在不同句子里会得到不同的向量。对中文 NER 来说,这把多义词消歧和边界判断都往前推了一步。
中文 BERT 模型通常按字切分,一个汉字对应一个 token,不存在分词错误向下游传播的问题。这个特性对实体识别尤其友好,因为很多人名、地名本身就是分词模型的盲区。标准 BERT-base 是 12 层、768 维、12 个注意力头,参数规模在 1 亿左右。这个量级单卡能跑,CPU 也能训练只是慢;相比更大的模型,它生态最成熟,遇到坑容易搜到答案,是我做中文序列标注的默认起点。
2.2 运行环境与依赖:一套能跑BERT的Python环境
先建一个干净的虚拟环境。我一般习惯用 conda 管理 Python 环境,避免不同项目之间互相污染依赖。Python 版本 3.8 以上都能用,选 3.8 主要是稳妥,transformers 和 torch 在新版和旧版之间都不会出兼容问题。
conda create -n bert_ner python=3.8 conda activate bert_ner装依赖的时候,torch 按自己的机器选 CPU 版或 GPU 版,命令不一样,安装前先确认本机的 CUDA 版本,否则装完也识别不到 GPU。transformers 负责加载预训练模型、调用 tokenizer、做微调训练,这是绕不开的核心库。
pip install torch pip install transformers装完先别急着拉大模型,先跑一行代码验证环境是否正常:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") print(tokenizer("某科技公司今天发布了新产品"))如果能打印出一串 token id,说明网络和依赖都没问题。很多人在环境上折腾大半天,最后发现是 transformers 版本和 torch 版本不匹配,在加载模型时报段错误。先让 tokenizer 跑起来,是最省事的体检方式。
2.3 中文预训练模型怎么挑:词表、参数与下载落地
中文任务首选bert-base-chinese,这个词表覆盖常用汉字,tokenizer 按字切分,不需要额外引入分词工具。另一个常见选择是全词掩码的中文 RoBERTa 系列,它在预训练时做了全词掩码,对中文下游任务往往有小幅提升,代价是 tokenizer 的读取方式不完全一样,老代码可能要小改。
我一般会先把模型下载到本地固定目录,而不是每次训练都让from_pretrained去互联网拉权重。原因很简单:模型文件不小,网络抖动会浪费时间,而且断网环境下代码直接不可用。加载代码:
from transformers import AutoTokenizer, AutoModelForTokenClassification model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForTokenClassification.from_pretrained( model_name, num_labels=7 )num_labels要和你的标签类别数严格一致。我这里用的是 O、B-PER、I-PER、B-ORG、I-ORG、B-LOC、I-LOC 这 7 类,后面数据准备里也是按 7 类做映射。加载完成后保存到本地:
tokenizer.save_pretrained("models/bert-base-chinese") model.save_pretrained("models/bert-base-chinese")之后不管是训练还是推理,都从models/bert-base-chinese加载。这里有一个容易被忽略的点:num_labels是在加载时写进模型配置的,如果保存后再想改标签数量,要重新加载原模型改配置,不要直接改config.json,容易把模型头部维度弄乱。
3. 数据与序列标注:把中文语料喂给BERT的三个注意点
3.1 BIO/BIOES标注格式与标签映射
中文 NER 的标注格式,最常用的是 BIO。B 表示实体开始,I 表示实体内部,O 表示非实体。下面用一个简短的例子说明:
| 字 | 标 签 |
|---|---|
| 某 | B-ORG |
| 科 | I-ORG |
| 技 | I-ORG |
| 公 | I-ORG |
| 司 | I-ORG |
| 今 | O |
| 日 | O |
| 宣 | O |
| 布 | O |
| 小 | B-PER |
| 陈 | I-PER |
| 介 | O |
| 绍 | O |
| 了 | O |
| 新 | O |
| 方 | O |
| 案 | O |
注意,中文场景下标签是按字对齐的,不是按词。语料如果给的是“某科技公司 / ORG”这样的词级标注,需要先展开成字级序列,再进入模型。标签到 id 的映射:
label_list = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"] label2id = {label: i for i, label in enumerate(label_list)} id2label = {i: label for i, label in enumerate(label_list)}BIOES 和 BIO 的区别是多出了 E(实体结束)和 S(单个字实体)两种标签,边界信息更丰富。如果数据量够,BIOES 效果会略好;但 BIO 更简单,标注一致性更好把控。我第一次做的时候选了 BIO,先把流程跑通,再迭代成 BIOES 不迟。
3.2 tokenizer对齐:为什么你的标签总在移位
BERT 的 tokenizer 会自动在序列开头加[CLS],结尾加[SEP],这会导致标签序列比原始文本多两个位置。如果直接拿原始字级别的标签去算 loss,全部错位。这就是中文 NER 最常见的静默 bug:loss 在下降,验证集却全是 O。
另外,虽然bert-base-chinese基本按字切分,但遇到英文单词或特殊符号时,仍可能切出##开头的子词,中文文本里不常见,通用代码里要兜住。对齐函数的写法:
def align_labels_with_tokens(chars, labels, tokens): aligned = [] idx = 0 for token in tokens: if token in ("[CLS]", "[SEP]", "[PAD]"): aligned.append(-100) continue if idx >= len(chars): aligned.append(-100) continue if token.startswith("##"): aligned.append(-100) continue aligned.append(label2id[labels[idx]]) idx += 1 return aligned这里的-100不是随机选的,它是 PyTorch 交叉熵损失函数里ignore_index的默认值。把特殊 token、padding 位置对应的标签设为-100,计算 loss 时这些位置会被自动忽略,不会污染梯度。
调用时的顺序是:先对文本做 tokenizer,拿到 tokens,再对原始字标签做对齐,最后把对齐结果作为 labels 传给模型。如果你发现某个实体标签总是错一位,多半是漏了[CLS]这一位,不是模型问题。
3.3 数据集切分与DataLoader:训练前要确认的几件事
数据准备好后,需要包一个 Dataset 类,把文本、标签、max_len 这些事情统一管理起来。下面是一个可以直接用的版本:
import torch from torch.utils.data import Dataset class NERDataset(Dataset): def __init__(self, texts, labels_seq, tokenizer, max_len=128): self.texts = texts self.labels_seq = labels_seq self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] char_labels = self.labels_seq[idx] encoded = self.tokenizer( [text], max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt", ) input_ids = encoded["input_ids"].squeeze(0) attention_mask = encoded["attention_mask"].squeeze(0) tokens = self.tokenizer.convert_ids_to_tokens(input_ids) label_ids = align_labels_with_tokens(list(text), char_labels, tokens) label_ids = label_ids[: self.max_len] label_ids.extend([-100] * (self.max_len - len(label_ids))) return { "input_ids": input_ids, "attention_mask": attention_mask, "labels": torch.tensor(label_ids, dtype=torch.long), }几个参数值得说清楚。max_length=128控制句子最大长度,超过会被截断,不足会被 padding。中文一句通常 100 字以内足够,但如果语料里有大段长文本,要按实际分布调大,同时警惕显存压力。padding="max_length"会把整个 batch 统一到同样长度,省去 DataLoader 侧手动 collate 的麻烦,缺点是短句也占满 128 个位置,batch 稍大一点就吃显存。
训练前还要做一次训练集、验证集、测试集切分。千万不要把所有数据都喂进去训练,等到推理才发现模型是在“背答案”。我通常按 8:1:1 切,切的时候按文件或按句随机,别按原始顺序切,否则数据分布可能严重偏斜。DataLoader 直接用它:
from torch.utils.data import DataLoader train_dataset = NERDataset(train_texts, train_labels, tokenizer) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)shuffle=True只对训练集有意义,验证和测试阶段要关掉。中文 NER 的数据切分还有一个容易忽略的场景:同一份语料里同一实体反复出现,如果不去重就切分,模型可能在训练集中见过验证集的实体,指标虚高。严格一点的做法是先对文档级别去重,再做句子级切分。
4. 训练避坑与排查:显存、过拟合、标签错位的真实翻车记录
4.1 训练参数选型:从学习率到训练循环的习惯值
微调 BERT 和训练普通网络不一样,因为预训练权重已经有很好的语义表示,学习率要小,通常设置在 2e-5 到 5e-5 之间,太大了会直接毁掉预训练特征。我用过最稳的组合是初始学习率 3e-5、batch size 16、epoch 5、warmup 比例 0.1。下面是训练循环的核心代码:
from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) total_steps = len(train_loader) * 5 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps, ) def train_one_epoch(model, train_loader, optimizer, scheduler): model.train() total_loss = 0.0 for batch in train_loader: input_ids = batch["input_ids"] attention_mask = batch["attention_mask"] labels = batch["labels"] outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels, ) loss = outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() return total_loss / len(train_loader)AdamW里的weight_decay=0.01对 BERT 微调是常规配置,防止全连接层过拟合。get_linear_schedule_with_warmup的意思是前 10% 步数学习率从零线性升到 3e-5,之后逐步衰减到零。warmup 的存在是因为 BERT 底层的 Transformer 层对突然的大更新很敏感,没有 warmup 的话,头几个 step 容易出现 loss 突跳。训练过程里,验证集 loss 降到一定程度不再降,就要停止,不要盲目跑满所有 epoch。
4.2 坑一:显存溢出与最大长度
现象:训练跑到一半,GPU 显存没占满,程序却报 OOM,或者直接 killed。更离谱的是同一个 batch size 在短句上没问题,换成长语料后立刻爆掉。
原因:padding="max_length"把所有样本都填到固定长度,一批长文本会导致实际计算量远大于预估值;另外 Transformer 的自注意力复杂度是序列长度的平方,长度翻倍,显存占用不是翻倍,是接近四倍。我一度以为是 batch size 的问题,把 batch 从 16 调到 8 仍然爆,最后才意识到是 max_len 的锅。
解决:先看训练语料的长度分布,取 95 分位作为max_len,不要拍脑袋填 512。如果必须处理长文本,先用截断或分段策略,比如按句子切分,而不是硬上长序列。还有一个补救办法是梯度累积,用小 batch 累积多次再更新,但显存压力本质没变,只解决了更新稳定性。
4.3 坑二:标签错位导致loss不降或全O输出
现象:训练 loss 看起来在下降,但不到 0.1 就卡住;验证集预测结果几乎全是 O,一个实体都识别不出来。
原因:最常见的是标签序列没做对齐。BERT tokenizer 加了[CLS]和[SEP],如果直接把原始字标签丢给模型,从第一个位置开始就错位了。另一个隐蔽原因是 padding 部分标签没有设成忽略值,模型学到把 padding 位置也映射成某个实体类别。
解决:每次训练前,单独跑一遍对齐验证。取一条句子,打印 token 序列和标签序列,人工核一遍长度和对应关系。我后来养成了一个习惯:用align_labels_with_tokens生成标签后,再随机挑 3 个样本检查tokens和labels是否能对上,再进训练循环。这个检查只需要十几秒,能省掉一整个晚上的重训。
4.4 坑三:实体指标虚高,准确率是假的
现象:训练报告里 token 级别的准确率到了 0.95,看起来很漂亮;但实际抽出来的实体七零八落,要么少头少尾,要么边界大一圈。
原因:序列标注里 O 标签占了绝大多数,模型只要把大部分句子都预测成 O,token 准确率就很高,但这个指标没有反映实体边界预测质量。实体识别看的是“抽出来的整段实体对不对”,不是每个字分得对不对。
解决:用实体级别的精确率、召回率、F1 来评估。先按 BIO 规则把预测标签合并成实体,再和真实实体做精确匹配:
def merge_entities(tokens, tags): entities = [] cur_entity = None for token, tag in zip(tokens, tags): if tag.startswith("B-"): if cur_entity: entities.append(cur_entity) cur_entity = {"type": tag[2:], "text": token} elif tag.startswith("I-") and cur_entity and cur_entity["type"] == tag[2:]: cur_entity["text"] += token else: if cur_entity: entities.append(cur_entity) cur_entity = None if cur_entity: entities.append(cur_entity) return entities这个方法把“某科技公司”这种连续字拼成一个实体文本,然后和标注里的实体做匹配。计算时不要用 set 去重,因为同一实体出现多次,去重会掩盖漏检问题。从那以后我记得很清楚:任何训练报告里只写 token 准确率的,我一律不看,实体级 F1 才是能交付的底线。
5. 推理与进阶:加载本地模型跑实体抽取
5.1 推理函数与输出解析
模型训练完,用save_pretrained保存到本地,推理时直接从本地目录加载,不再走from_pretrained的远程下载。推理代码和训练代码套路一致,只是少了反向传播:
model = AutoModelForTokenClassification.from_pretrained("models/ner_model") tokenizer = AutoTokenizer.from_pretrained("models/ner_model") def predict_entities(text): inputs = tokenizer([text], truncation=True, return_tensors="pt") with torch.no_grad(): logits = model(**inputs).logits preds = logits.argmax(dim=-1)[0].tolist() tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]) tags = [id2label[p] for p in preds] entities = merge_entities(tokens, tags) return [e for e in entities if e["text"] not in ("[CLS]", "[SEP]")]truncation=True在这里不加padding,因为推理只处理一条句子,不需要补成固定长度。argmax(dim=-1)取每个 token 在标签分布上概率最大的位置。merge_entities会把 B-PER 开头的标签一路拼到非 I 标签为止,这样返回的实体是完整的字符串,方便直接对接下游业务。
5.2 进阶方向
如果当前模型在特定领域效果不理想,最快提升路径是拿领域语料继续微调,比如把通用模型在模拟项目X的标注数据上再跑 2 个 epoch,一般就能看出明显变化。数据量不足时,先用小学习率做领域自适应,再微调分类层,会比一步到位更稳。如果对推理速度有要求,可以尝试半精度推理或转成 ONNX,但中文 BERT 序列标注的边界和原来不会差太多,值得在模型稳定后再考虑。
多说一句踩过的教训:每次换数据或改标签,我都会强制走一遍“标注检查、对齐检查、实体级评估”三件事,确认没有错位再批量训练。状态一快,最容易漏的就是标签对齐这类基础动作,而它几乎决定了模型的天花板。希望帮到你。
本文还有配套的精品资源,点击获取