news 2026/11/2 2:08:10

BERT微调实战:中文命名实体识别从数据到部署全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BERT微调实战:中文命名实体识别从数据到部署全流程

简介:这份资源面向具备一定Python基础、希望入门或进阶自然语言处理的学习者,聚焦如何借助预训练BERT模型完成中文命名实体识别任务,解决中文文本中人名、地名、机构名等实体抽取的实践问题。压缩包共9个文件,以txt语料与词表、py训练脚本、pl评估脚本及md说明文档为主,另含示例图片,整体约3.72MB,体量轻便,便于本地快速跑通。已有3346人学习下载,具备一定的参考热度。内容围绕BERT双向编码器结构、MLM与NSP预训练任务、IOB标注体系及Precision/Recall/F1评估指标展开,并涉及Hugging Face Transformers库的模型加载与微调、中文分词与特殊令牌处理、序列填充、AdamW优化器与学习率调度等环节,配套训练集、验证集与测试集数据,可帮助读者理解从数据预处理到模型训练评估的完整链路,适合作为中文NER实战的入门参考。

1. 中文 NER 为什么值得用 BERT 重做一遍

去年帮一个做合同审阅的团队看他们的实体抽取流水线,规则加词典跑了大半年,准确率卡在 78% 上不去,最要命的是人名和机构名交叉的场景——"某某银行上海分行"这种,词典里既有"某某银行"又有"上海",切出来的边界全是错的。这类问题不是加几条正则能救的,本质是模型没见过上下文。换成预训练语言模型 BERT 做序列标注之后,同一批测试集直接拉到 92% 左右,边界错误少了一大半。

这份资源就是围绕"用 BERT 做中文命名实体识别"这条线整理的:从数据标注格式、Tokenizer 对齐、模型加载、训练脚本到推理部署,给了一套能直接跑起来的 Python 工程。适合两类人——手上有中文文本要抽人名/地名/机构名/专有名词的 NLP 从业者,以及想搞明白 BERT 微调在序列标注任务上到底怎么落地的新手。下面按"先立住原理、再动手复现、最后讲坑"的顺序拆。

2. 从 BIO 标注到 Dataset:数据管线怎么搭才不出错

中文 NER 的翻车点,八成不在模型,在数据。标签体系和文本切分没对齐,后面训练再调参都是白费。这一章把数据从原始标注到喂进模型的完整链路讲清楚。

2.1 标签体系选 BIO 还是 BMES

常见的中文 NER 标签体系有两种:BIO 和 BMES。BIO 是 B-(实体开始)、I-(实体内部)、O(非实体),BMES 是 B-(开始)、M-(中间)、E-(结束)、S-(单字实体)。BIO 简单,但有个天然缺陷:两个相邻的同类型实体没法区分边界,比如"张三李四"连着出现,BIO 会标成 B-PER I-PER I-PER I-PER,模型分不清这是一个人还是两个人。BMES 用 E- 显式标结束,能规避这个问题。

我一般建议:实体边界要求严格的场景(合同、病历、法律文书)用 BMES;一般信息抽取用 BIO 也够。这份资源默认走 BIO,因为和主流开源标注工具兼容性好,改 BMES 只需要动标签映射表。

标签映射表长这样,注意顺序必须固定,训练和推理要用同一份:

# label_map.py # 顺序一旦确定,训练、评估、推理三处必须完全一致,否则标签错位 LABEL_LIST = [ "O", "B-PER", "I-PER", "B-LOC", "I-LOC", "B-ORG", "I-ORG", ] LABEL2ID = {label: idx for idx, label in enumerate(LABEL_LIST)} ID2LABEL = {idx: label for label, idx in LABEL2ID.items()}

LABEL_LIST的顺序就是模型输出 logits 的维度顺序,num_labels=len(LABEL_LIST)=7。这里最容易犯的错是训练时用了一套顺序,推理时重新sorted()了一遍,结果 B-PER 和 B-LOC 对调,评估指标看着还行,实际抽出来的实体全串了。血泪经验:标签表单独存一个 json,训练脚本和推理脚本都从同一个文件读。

2.2 字符级切分与 subword 对齐

中文 BERT 的 Tokenizer 是字级别的,但遇到英文、数字、特殊符号会切成 subword,一个汉字可能对应一个 token,一个英文单词可能对应多个 token。序列标注要求"一个 token 一个标签",所以必须处理 subword 对齐——通常做法是只保留每个词第一个 subword 的标签,其余 subword 标成 -100(PyTorch 的 CrossEntropyLoss 会忽略 -100)。

# align_labels.py from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") def align_labels_with_tokens(labels, word_ids): """把字符级标签对齐到 subword 级 labels: 原始字符级标签 id 列表 word_ids: tokenizer 返回的每个 token 对应的原词索引,None 表示特殊符号 """ new_labels = [] previous_word_idx = None for word_idx in word_ids: if word_idx is None: # [CLS] [SEP] [PAD] 等特殊 token,不参与 loss new_labels.append(-100) elif word_idx != previous_word_idx: # 每个词的首个 subword 保留原标签 new_labels.append(labels[word_idx]) else: # 同一词的后续 subword 忽略 new_labels.append(-100) previous_word_idx = word_idx return new_labels

关键参数说明:word_ids是tokenizer(..., return_offsets_mapping=True)或is_split_into_words=True时返回的,它告诉你第 i 个 token 来自原始输入的第几个词。-100是 PyTorchCrossEntropyLoss的默认ignore_index,别改成别的值,否则 loss 会把 padding 也算进去,训练曲线会莫名其妙地高。

2.3 构建 Dataset 与动态 padding

数据量大的时候,把所有样本 pad 到最大长度是灾难——90% 的算力浪费在 padding 上。正确做法是用DataCollatorForTokenClassification做 batch 内动态 padding。

# dataset.py from torch.utils.data import Dataset from transformers import DataCollatorForTokenClassification class NerDataset(Dataset): def __init__(self, texts, label_seqs, tokenizer, max_len=128): self.texts = texts self.label_seqs = label_seqs self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] labels = [LABEL2ID[l] for l in self.label_seqs[idx]] # is_split_into_words=True 表示输入已经是字符列表 encoding = self.tokenizer( list(text), is_split_into_words=True, truncation=True, max_length=self.max_len, return_offsets_mapping=True, ) word_ids = encoding.word_ids() encoding["labels"] = align_labels_with_tokens(labels, word_ids) # offsets_mapping 不能进模型,训练前删掉 encoding.pop("offset_mapping") return {k: torch.tensor(v) for k, v in encoding.items()} # collator 负责 batch 内 pad,labels 用 -100 填充 collator = DataCollatorForTokenClassification( tokenizer=tokenizer, label_pad_token_id=-100, )

is_split_into_words=True是这里的关键,它让 tokenizer 把输入当成"已经切好的词序列"处理,word_ids()才有意义。如果直接传整句字符串,word_ids()返回的全是 None,对齐逻辑直接失效。max_len=128对中文 NER 够用,长文本建议先按句切分再送进来,别硬拉长到 512,显存和效果都不划算。

3. 模型加载与微调:BERT 序列标注的实操配置

数据管线通了,接下来是模型。这一章讲清楚加载预训练权重、加分类头、设学习率这几个决定成败的环节。

3.1 AutoModelForTokenClassification 的正确打开方式

HuggingFace 的AutoModelForTokenClassification会在 BERT 的[CLS]输出上自动加一个线性分类头,输出维度等于num_labels。加载时有两个参数必须显式传:num_labels和id2label/label2id,后者决定了保存模型时配置文件里记录的标签映射,推理端能直接读回来。

# model.py from transformers import AutoModelForTokenClassification, AutoConfig config = AutoConfig.from_pretrained( "bert-base-chinese", num_labels=len(LABEL_LIST), id2label=ID2LABEL, label2id=LABEL2ID, ) model = AutoModelForTokenClassification.from_pretrained( "bert-base-chinese", config=config, )

bert-base-chinese是 12 层、768 隐藏维度、110M 参数量的基础版,中文 NER 任务上够用。如果数据量超过 5 万条、实体类型超过 10 类,可以换bert-large-chinese,但显存需求翻倍,单卡 24G 跑 batch_size=16 会吃紧。常见做法是先跑 base 版看指标,不够再上 large,别一上来就 large,调参周期会拖得很长。

3.2 学习率与 warmup:别用默认值

BERT 微调的学习率是个玄学参数,用 AdamW 默认的 1e-3 基本必崩——预训练权重被冲垮,loss 前几个 step 就炸到 nan。序列标注任务的常见配置是lr=2e-5到5e-5,warmup 比例 10%。

# train.py from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir="./ner_output", learning_rate=3e-5, # 序列标注常用区间 2e-5 ~ 5e-5 per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, # 中文 NER 一般 3~5 轮收敛 weight_decay=0.01, warmup_ratio=0.1, # 前 10% step 线性 warmup evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", logging_steps=50, fp16=True, # 有 GPU 就开,省显存提速 )

参数逐个说:learning_rate=3e-5是 base 版的经验值,large 版建议降到 1e-5;num_train_epochs=5配合load_best_model_at_end能自动回滚到验证集 F1 最高的 checkpoint,相当于给自己留了后悔药;fp16=True在支持 Tensor Core 的卡上能省近一半显存,但要注意 loss scale,如果出现 nan 就关掉或换 bf16。

3.3 评估指标:seqeval 算的是实体级 F1

token 级准确率在 NER 上没有意义——O 标签占 80% 以上,全预测 O 也能有 80% 准确率。必须用实体级 F1,seqeval是标准工具。

# metrics.py from seqeval.metrics import f1_score, precision_score, recall_score def compute_metrics(eval_pred): logits, labels = eval_pred predictions = logits.argmax(axis=-1) true_labels = [] pred_labels = [] for pred_seq, label_seq in zip(predictions, labels): cur_true, cur_pred = [], [] for p, l in zip(pred_seq, label_seq): if l == -100: continue cur_true.append(ID2LABEL[l]) cur_pred.append(ID2LABEL[p]) true_labels.append(cur_true) pred_labels.append(cur_pred) return { "precision": precision_score(true_labels, pred_labels), "recall": recall_score(true_labels, pred_labels), "f1": f1_score(true_labels, pred_labels), }

注意l == -100的过滤必须在转标签之前做,否则ID2LABEL[-100]直接 KeyError。seqeval的 F1 是严格匹配——实体的起止位置和类型全对才算对,比 token 级严格得多,这也是为什么它更能反映真实效果。

4. 推理与部署:从 checkpoint 到可用服务

训练完的模型不能直接扔给业务方,得包成能调用的接口。这一章讲推理脚本怎么写、批量推理怎么提速、以及一个容易被忽略的 tokenizer 一致性问题。

4.1 单条推理与实体还原

推理的核心是把模型输出的标签序列还原成"实体文本 + 类型 + 起止位置"。BIO 体系下,遇到 B- 开新实体,遇到 I- 延续,遇到 O 或类型不匹配就收尾。

# inference.py import torch from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("./ner_output/best") model = AutoModelForTokenClassification.from_pretrained("./ner_output/best") model.eval() def extract_entities(text, max_len=128): inputs = tokenizer( list(text), is_split_into_words=True, return_tensors="pt", truncation=True, max_length=max_len, ) with torch.no_grad(): logits = model(**inputs).logits pred_ids = logits.argmax(-1)[0].tolist() word_ids = inputs.word_ids() entities = [] cur = None for token_idx, (pid, wid) in enumerate(zip(pred_ids, word_ids)): if wid is None: continue label = ID2LABEL[pid] if label.startswith("B-"): if cur: entities.append(cur) cur = {"type": label[2:], "start": wid, "end": wid + 1} elif label.startswith("I-") and cur and label[2:] == cur["type"]: cur["end"] = wid + 1 else: if cur: entities.append(cur) cur = None if cur: entities.append(cur) return [ {"text": text[e["start"]:e["end"]], "type": e["type"], "start": e["start"], "end": e["end"]} for e in entities ]

word_ids()在这里又出现了一次,它把 token 位置映射回原文字符位置,text[start:end]才能切出正确的实体文本。如果跳过这步直接用 token 索引切原文,遇到英文或数字就会错位——这是推理阶段最常见的翻车点。

4.2 批量推理的显存与吞吐权衡

线上服务一般要批量处理,但 batch 太大会 OOM,太小吞吐上不去。我的做法是按文本长度分桶,同桶内 batch,避免短文本被长文本拖累。

# batch_infer.py def batch_extract(texts, batch_size=32): # 按长度排序后分批,减少 padding 浪费 indexed = sorted(enumerate(texts), key=lambda x: len(x[1])) results = [None] * len(texts) for i in range(0, len(indexed), batch_size): chunk = indexed[i:i + batch_size] batch_texts = [t for _, t in chunk] encodings = tokenizer( [list(t) for t in batch_texts], is_split_into_words=True, return_tensors="pt", padding=True, truncation=True, max_length=128, ) with torch.no_grad(): logits = model(**encodings).logits # 逐条解码,逻辑同单条推理 for j, (orig_idx, _) in enumerate(chunk): results[orig_idx] = decode_one(logits[j], encodings.word_ids(j), batch_texts[j]) return results

padding=True配合排序后的分批,能把 padding 比例压到 10% 以内。word_ids(j)取第 j 条的映射,注意 batch 内每条的长度不同,解码时要按各自的word_ids处理,不能共用。

4.3 tokenizer 一致性:训练和推理必须同一份

这个坑我踩过两次。训练时用bert-base-chinese的 tokenizer,推理时图省事从另一个路径加载了同名 tokenizer,结果词表版本不一致,某些特殊符号的 id 对不上,实体边界整体偏移。解决方式很简单:训练结束后把 tokenizer 和模型一起save_pretrained到同一个目录,推理只从那个目录加载。

# 训练结束时保存 model.save_pretrained("./ner_output/best") tokenizer.save_pretrained("./ner_output/best")

目录里会有vocab.txt、tokenizer_config.json、config.json、pytorch_model.bin,推理端from_pretrained("./ner_output/best")一次性加载,杜绝版本漂移。

5. 避坑与排查:五个真实翻车现场

这一章全是踩过的坑,按"现象 → 原因 → 解决"写,照着排查能省不少时间。

5.1 loss 一直是 nan

现象:训练第一个 epoch 就 nan,或者前几十 step 正常然后突然 nan。原因通常是学习率过大把预训练权重冲垮,或者 fp16 的 loss scale 溢出。解决:先把learning_rate降到 1e-5 试,还 nan 就关fp16,再不行检查数据里有没有全 -100 的样本(整条被截断成特殊 token),这种样本会让 loss 变成 nan,在 Dataset 里过滤掉长度小于 2 的样本。

5.2 验证集 F1 很高,上线效果很差

现象:离线评估 F1 0.93,业务方反馈抽出来的实体一半是错的。原因八成是训练集和线上数据的分布不一致——训练数据是标注员精挑的干净文本,线上是带错别字、带 HTML 标签、带乱码的原始文本。解决:从线上真实数据里抽 500 条人工标一遍做验证集,别用训练集切出来的那部分;另外在预处理里加一步清洗,去掉 HTML 标签和连续空白。

5.3 实体边界总是多一个字或少一个字

现象:抽"北京市"抽成"北京",抽"张三丰"抽成"张三"。原因是 subword 对齐时-100的位置处理错了,或者推理时用 token 索引切原文。解决:检查align_labels_with_tokens里word_idx != previous_word_idx的判断,确保每个词的首个 subword 保留标签;推理端确认用的是word_ids()映射回字符位置,不是 token 位置。

5.4 长文本被截断,后半段实体全丢

现象:超过 128 字的文本,后半段实体一个都抽不出来。原因是max_length=128直接截断,没做滑窗。解决:长文本按标点切句,每句单独推理再合并;或者用滑窗,窗口 128、步长 64,重叠部分做实体去重。别直接把max_length拉到 512,BERT 的位置编码虽然支持,但长文本的注意力稀释会让效果下降。

5.5 多卡训练后模型加载报错

现象:单卡训练正常,多卡 DDP 训练完from_pretrained报 key 不匹配。原因是 DDP 保存的 state_dict 带了module.前缀。解决:保存时用model.module.save_pretrained(...),或者加载时用from_pretrained(..., strict=False)忽略前缀差异。更稳妥的做法是训练脚本里统一用Trainer,它内部处理了 DDP 的保存逻辑。

6. 进阶技巧:用 CRF 层和对抗训练再榨几个点

基础版 BERT + 线性分类头能到 90 出头,想再往上走,有两个性价比高的方向:加 CRF 层约束标签转移,以及用 FGM 做对抗训练提升鲁棒性。

CRF 层的价值在于建模标签之间的转移概率——比如 I-PER 后面接 B-LOC 的概率应该很低,线性分类头学不到这个约束,CRF 能显式建模。实现上在 BERT 输出后接一个nn.CRF,训练时算负对数似然,推理时用 Viterbi 解码。代价是训练慢 20% 左右,但边界一致性会明显改善,尤其是实体密集的文本。

# crf_head.py(示意,需配合 torchcrf 库) import torch.nn as nn from torchcrf import CRF class BertCrfForNER(nn.Module): def __init__(self, bert, num_labels): super().__init__() self.bert = bert self.dropout = nn.Dropout(0.1) self.classifier = nn.Linear(bert.config.hidden_size, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) emissions = self.classifier(self.dropout(outputs.last_hidden_state)) # mask 掉 padding 位置,CRF 不参与计算 mask = attention_mask.bool() if labels is not None: loss = -self.crf(emissions, labels, mask=mask, reduction="mean") return {"loss": loss} return {"pred": self.crf.decode(emissions, mask=mask)}

mask参数必须传,否则 CRF 会把 padding 位置的转移也算进去,训练出来的转移矩阵是错的。torchcrf这个库不在 transformers 依赖里,需要单独pip install pytorch-crf。

对抗训练用 FGM(Fast Gradient Method),在每个 batch 反向传播前,对 embedding 层加一个沿梯度方向的扰动,再算一次 loss。实现大概十几行,能让模型对输入噪声更鲁棒,线上错别字场景的 F1 通常能涨 1 到 2 个点。具体做法是在Trainer的compute_loss里插入扰动步骤,或者自己写训练循环。

这两个技巧不是必须的,基础版跑通、指标稳定之后再上。我一般会先确保 baseline 的 F1 和线上一致,再逐个加 trick,每次只改一个变量,用同一份验证集对比。从那以后我每次上这类优化,都强制先跑一遍 baseline 存档,不然改着改着就不知道是哪个改动带来的收益了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 2:08:02

GPS轨迹降噪:Python API与算法流水线

简介:面向需要清洗GPS轨迹数据的开发者与算法学习者,这份资源聚焦噪点剔除与异常点处理,结合高德、百度轨迹服务接口与基于点间距离的降噪思路,可用于智能交通、物流跟踪和户外运动记录等场景。压缩包共3个文件,均为Py…

作者头像 李华
网站建设 2026/11/2 2:07:50

DeepSeek工程实践:模型选型、API接入、参数调优与避坑自检

简介:《DeepSeek从入门到精通》是一份来自清华大学新闻与传播学院新媒体研究中心的PDF学习材料,面向AI初学者、开发者及内容从业者,帮助读者建立对DeepSeek-R1推理模型的整体认知,并掌握从基础使用到提示语设计的进阶方法。资源为…

作者头像 李华
网站建设 2026/11/2 2:06:48

.NET Core WebAPI 文件服务:流式上传下载与断点续传

简介:这套面向 .NET Core 开发者的 WebAPI 文件服务源码,聚焦上传与下载能力的工程化实现,适合已有一定框架基础、希望参考完整项目结构来搭建文件接口、中间件和演示调用的中级后端开发者。压缩包共 50 个文件、约 206KB,以 25 个…

作者头像 李华
网站建设 2026/11/2 2:06:38

CentOS离线安装stress:RPM与源码编译及压测排错指南

简介:面向 Linux 运维、测试与性能验证人员,提供 CentOS 环境下 stress 压力测试工具的离线安装包,适合无外网或受限网络服务器快速部署,并开展 CPU、内存、I/O 等基础压测。压缩包共 43 个文件,约 46.66MB&#xff0c…

作者头像 李华
网站建设 2026/11/2 2:06:37

CentOS离线安装stress:rpm与源码编译及CPU/内存/IO压测

简介:面向Linux运维与性能测试初学者,这份离线安装包解决内网或受限环境下无法通过yum在线获取压测工具的问题。包内以stress-1.0.4源码为主体,含configure、install-sh等构建脚本与说明文档,并附带gcc-g等共11个rpm依赖包&#x…

作者头像 李华
网站建设 2026/11/2 2:06:03

C++实现旋转数组的二分查找

前言 「旋转数组」指的是把一个升序数组在某个位置切开、把后段接到前面,例如 [0,1,2,4,5,6,7] 在索引 3 处旋转得到 [4,5,6,7,0,1,2]。整体不再有序,但局部仍然有序,这个「两段各自有序」的结构正是二分查找还能工作的原因。 典型误解是认为…

作者头像 李华