ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

BERT中文NER实战指南:从微调到部署的完整避坑方案

BERT中文NER实战指南:从微调到部署的完整避坑方案 简介面向自然语言处理初学者与Python开发者的中文命名实体识别实战资源围绕BERT预训练模型讲解并实现人名、地名、组织名等实体的识别流程。压缩包共9个文件包含可运行的Python脚本、中文训练验证测试数据集、词表、说明文档、评估脚本及效果示意图整体仅3.72MB轻量便于本地快速复现。内容覆盖双向Transformer结构、掩码语言模型与下一句预测预训练任务、IOB标注体系、数据预处理、损失函数与优化器选择、微调评估等关键知识点还提供了从文本标记化、特殊令牌添加到模型训练与效果评估的完整思路能帮助读者从零搭建中文NER训练流程并掌握Precision、Recall、F1等指标的含义。已有3335人学习下载适合作为BERT与中文NLP项目的入门参考也方便在此基础上扩展实体类别或接入自有数据是了解预训练模型落地中文NLP任务的不错范例。1. 预训练语言模型BERT做NER为什么一套跑通的代码也会“翻车”中文NER命名实体识别任务看起来门槛不高装上transformers加载一个bert-base-chinese随便一段文本就能跑出标签但真正要用预训练语言模型做中文NER从数据标注格式、tokenizer对齐到微调训练和部署每一步都能让一套跑通的代码“翻车”。这篇笔记把常见的落地路径完整走一遍先用BERT做最小推理验证环境再把标注数据转成token级标签做微调最后给出切分长文本和部署HTTP接口的写法。适合刚开始接触预训练语言模型、要做人名地名机构名提取的工程师照着做。2. 用transformers加载bert-base-chinese最短推理路径与tokenizer对齐问题2.1 最小可跑代码pipeline推理起步阶段先确认Python环境最好在3.9以上PyCharm和VSCode都行。反正后面要用torch创建虚拟环境是常见做法python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install transformers torch然后跑一个最小推理from transformers import pipeline ner pipeline( token-classification, modelbert-base-chinese, device0 # CPU环境改成-1 ) text 王小云在2023年参加了中国计算机大会地点在深圳。 results ner(text, aggregation_strategymax) for r in results: print(r[word], r[entity], round(r[score], 4))这里的pipeline会把模型自动加载到GPUdevice0没有GPU时改成-1。aggregation_strategymax的作用是把被切开子词合并回原词并保留得分最高的标签中文场景下人名可以正常读取。注意如果直接加载原始的bert-base-chinese因为不是经过NER任务微调的权重输出的标签基本没有业务意义这里的作用是验证模型下载、CUDA调用和分词是否正常。如果是第一次跑它会去下载模型权重网络不好时建议先看本章第2.3节。2.2 手写forward与offset对齐把pipeline换成手写调用有两层意义覆盖更多可调粒度以及在真实业务中把标签映射掌握在自己手里。代码如下from transformers import AutoTokenizer, AutoModelForTokenClassification import torch tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForTokenClassification.from_pretrained( bert-base-chinese, num_labels4 ) label_list [O, B-PER, I-PER, B-ORG] text 王小云在2023年参加中国计算机大会 inputs tokenizer(text, return_tensorspt) logits model(**inputs).logits pred_ids logits.argmax(-1)[0] tokens tokenizer.convert_ids_to_tokens(inputs[input_ids][0]) for token, pred in zip(tokens, pred_ids): label label_list[int(pred)] if label ! O: print(token, label)这里num_labels4只是把token分类头的输出尺寸定义成4在未微调时索引0、1、2、3和“O、B-PER、I-PER”之间没有任何真实映射关系所以这个代码的正确作用是看清结构BERT输出[B, seq_len, num_labels]的logitsargmax之后每个token拿到一个标签id真正需要关注的是token和原文字符的对应关系。BERT输入里多出的[CLS]和[SEP]占两个位置英文、数字还会被WordPiece拆成多个子词所以token的下标和字符串的下标不会一一对应。唯一可靠的对应只能靠tokenizer的offset_mapping。之后做微调时这个问题会成为第一个大坑。2.3 模型权重下载与缓存管理from_pretrained会自动管理权重。Linux下模型缓存在~/.cache/huggingface/hubWindows在C:\Users用户名.cache\huggingface\hub。第一次加载会下载config.json、tokenizer文件、pytorch_model.bin这几个关键内容中文模型是bert-base-chinese这个目录也就是社区里常说的预训练参数下载之后所在的位置。网络不稳定的环境下常见做法是设置环境变量把下载指向国内镜像再正常from_pretrainedexport HF_ENDPOINThttps://hf-mirror.com也可以在浏览器里手动下载bert-base-chinese的config.json、pytorch_model.bin、vocab.txt、tokenizer_config.json四个文件放到本地目录比如./bert-base-chinese然后用本地路径加载tokenizer AutoTokenizer.from_pretrained(./bert-base-chinese) model AutoModelForTokenClassification.from_pretrained(./bert-base-chinese)要注意模型文件下载中断时经常产生损坏的bin文件加载时会报一些不好定位的错误此时删除缓存目录里对应的临时文件再重新下载即可。还有一个细节模型加载时如果label2id / id2label不一致下一步微调的标签顺序会错乱这个问题在第4章里单独说。3. 中文NER微调从BIES标注到BERT训练的标准流程3.1 把标注转成token级标签通常NER标注格式为BIO、BIES中文语料经常一个回车一个“字 标签”空行表示句子边界。而模型期望的labels是一个和input_ids等长的整数列表并且padding后无效位置必须用-100。先定义标签映射label_map { O: 0, B-PER: 1, I-PER: 2, E-PER: 3, B-ORG: 4, I-ORG: 5, E-ORG: 6, B-LOC: 7, I-LOC: 8, E-LOC: 9 } id_to_label {v: k for k, v in label_map.items()}接下来是把字符级标签对齐到token上最常见的写法是借助offset_mapping单独处理[CLS]和[SEP]def encode_sentence(sentence, char_labels, tokenizer, label_map, max_len128): enc tokenizer( sentence, max_lengthmax_len, truncationTrue, return_offsets_mappingTrue ) label_ids [-100] * len(enc[input_ids]) offset enc[offset_mapping] for i in range(1, len(enc[input_ids]) - 1): start, end offset[i] if start 0 and end 0: continue # start和end是当前token覆盖原文字符串的区间 piece_labels [label_map[char_labels[pos]] for pos in range(start, end)] first_non_o next((x for x in piece_labels if x ! label_map[O]), label_map[O]) label_ids[i] first_non_o return { input_ids: enc[input_ids], attention_mask: enc[attention_mask], labels: label_ids }注意这里的char_labels是针对原始字符串中每个字符的标签列表。比如“王小云 在 2023 年”这串文字如果每个中文字符都对应一个标签那么char_labels列表的长度和len(sentence)相同。tokenizer会输出形如[0, 2)这样的区间表示token对应原文字符下标0到1把这个区间内所有字符的标签收集起来如果里面出现非O标签就把这个token标记成第一个非O标签否则是O。这样即使一个英文单词被切成两三个子词子词和字符的对齐也不会错位。有个常见的误用是直接拿“字符-标签”列表的index硬怼到input_ids的index上认为token的长度等于句子长度。中文短句时看起来对一旦遇到英文、数字或[CLS][SEP]就翻车。这也是为什么不推荐在处理中文NER时把模型输入当成“逐字序列”来写。3.2 微调训练脚本与最小命令数据部分用一个列表装起来即可每个元素就是encode_sentence的返回值然后交给Trainer。如果数据量大再用datasets库做map但先跑通小数据更重要from transformers import BertForTokenClassification, Trainer, TrainingArguments model BertForTokenClassification.from_pretrained( bert-base-chinese, num_labelslen(label_map) ) train_args TrainingArguments( output_dir./checkpoints, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, weight_decay0.01, num_train_epochs3, eval_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modeleval_loss, fp16True, logging_steps100 ) trainer Trainer( modelmodel, argstrain_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, ) trainer.train() model.save_pretrained(models/bert-ner) tokenizer.save_pretrained(models/bert-ner)这里用Hugging Face Trainer集合是当前最直接的方案。fp16一定要在支持CUDA的GPU上才开纯CPU机器要改成False如果显存只有6GB把batch_size调成8并用gradient_accumulation_steps2补足等效batch。eval_strategy是transformers新版本里的字段名旧版本是evaluation_strategy两版都会给出兼容警告不影响运行。训练结束后直接用本地路径加载from transformers import AutoTokenizer, AutoModelForTokenClassification model AutoModelForTokenClassification.from_pretrained(models/bert-ner) tokenizer AutoTokenizer.from_pretrained(models/bert-ner)注意保存模型时label2id会被写进config下次加载时分类头维度自动恢复所以decoding标签时要看model.config.id2label不要再用自己写的label_list硬编。3.3 几个必调参数与选型理由整理一下最关键的训练参数直接照抄模板再按机器调参数推荐区间说明learning_rate2e-5 ~ 5e-5BERT微调不适合用更大的学习率训崩很快per_device_train_batch_size8 ~ 32显存不够时调小搭配梯度累积num_train_epochs3 ~ 5中文NER小数据集3轮足够轮数过大容易过拟合max_length128 ~ 256实体一般不会太长太长浪费显存weight_decay0.01会衰减除bias之外的参数warmup_ratio0.1可以按比例设置选预训练模型时bert-base-chinese是最稳妥的默认选择。更强的Chinese-BERT-wwm其实只改了预训练时的全词掩码微调代码完全一致换模型只需要改加载路径效果通常会略好一点代价是权重更大。在真实项目中建议先用bert-base-chinese跑通再换成wwm版对比一次F1提升是否值得。词表方面bert-base-chinese对中文按字建模字和token基本一对一但英文和数字会被wordpiece拆开所以“2023”会变成四个token。这一点在NER里不能依赖中文分词工具而是要在offset层处理。4. BERT中文NER避坑五个最容易翻车的点4.1 坑1标签错位实体边界跑到下一个token上现象训练loss下降正常eval出来的随机抽取预测里实体总是比真实位置偏一位人名后一个字多一个“E-PER”之类。原因构造labels时直接用字符下标去压input_ids位置忽略了BERT在句首句尾加特殊token也忽略了英文被拆成子词后token数多于字符数。解决用tokenizer(..., return_offsets_mappingTrue)取offset_mapping以此建立token到原文区间的映射把原文里该区间内所有字符的标签合并到token上。要做到即使某个token覆盖了多个字B/I/E也是按字符真实标签顺序取并且padding后填-100。这一条是中文NER里误用率最高的地方数据转换前先检查一遍。4.2 坑2验证集F1虚高token级准确率看起来很美现象eval里accuracy很高但人工看实体输出发现漏检一堆。原因序列里绝大多数token是O类模型只要无脑预测O准确率都有95%以上。解决不要只看accuracy要按实体span来算precision / recall / F1。具体做法是相邻的B-和I-合并为一个实体E-结尾S-是单字实体最后拿预测实体集合和标注实体集合做匹配。第6章的验证代码可以直接复用。另外分实体类别统计而不是只算一个总体F1“ORG”漏检很可能被“PER”的高分覆盖掉。4.3 坑3显存不够、训练中途OOM现象训练到第二个epoch时CUDA out of memory只报step号。原因batch_size设得太大序列长度随句子变化padding后有一些批特别长。解决先看数据里最长句子把max_length切到覆盖90%样本的长度剩下的交给截断。batch_size从16降到8后用gradient_accumulation_steps2效果接近但显存消耗减半。fp16在Ampere之后架构上都能用显存占用再降三分之一。训练前先跑一次小批量推理确认显存占用余量。4.4 坑4英文和数字被WordPiece拆碎现象“2023年”里的“2023”被切成“202”“##3”之类labels怎么都对齐不上英语人名和中文人名混排时英语部分标签全变成O。原因bert-base-chinese对英文和数字按子词切分一个词可能对应多个token而标注数据里只有单字符粒度。解决用offset_mapping里整段区间做标签合并并把区间内首个非O标签继承到整段。另一个办法是预处理时把连续英文或数字整体替换成占位符比如“NUM”跑完NER再映射回原词这个思路在纯文本场景效率最高。但如果是金融、医疗等文本数字本身是实体的一部分不能直接替换这时还是用offset方式稳妥。4.5 坑5模型加载时huggingface下载失败或卡死现象from_pretrained一直卡在下载阶段或者报“Cant load tokenizer”找不到词表。原因网络环境对huggingface.co连接不可靠下载中断后缓存里留下损坏的临时文件。报错只说无法加载不提示是哪个文件坏了。解决先删掉缓存目录里对应的文件夹然后把环境变量HF_ENDPOINT设置成镜像地址再重试。实在不稳就手动下载config.json、pytorch_model.bin、vocab.txt、tokenizer_config.json放到本地目录用本地路径加载加载前可以用python检查pytorch_model.bin的文件大小是否和官网一致不一致就重新下载。这个坑几乎每个新手都会遇到一次属于环境层面的固定经验。5. 长文本切分与服务化部署滑动窗口、置信度过滤与HTTP接口5.1 滑动窗口切长文本BERT输入最多512个token中文场景几十个汉字就顶满业务文本往往上万字。直接truncate会砍掉后半句实体常见做法是滑动窗口切分重叠部分再合并。切分时按字符还是token按字符简单且不会因为tokenize导致窗口边界落进半截子词位。窗口长度设为120个中文字重叠40字这样保证足够的上下文也让切分处不会恰好把实体拆开。推理后的实体带有在原句中的字符偏移量合并时按偏移量去重def sliding_window_predict(text, tokenizer, model, window120, stride40, threshold0.5): entities [] start 0 while start len(text): seg text[start:start window] inputs tokenizer(seg, return_tensorspt, truncationTrue) logits model(**inputs).logits preds logits.argmax(-1)[0] probs logits.softmax(-1)[0] tokens tokenizer.convert_ids_to_tokens(inputs[input_ids][0]) current_entity None for idx, token in enumerate(tokens): label model.config.id2label[int(preds[idx])] score float(probs[idx][preds[idx]]) # 跳过特殊token if token in ([CLS], [SEP]): continue if label.startswith(B-): if current_entity is not None: entities.append(current_entity) current_entity {text: token, type: label[2:], score: score} elif label.startswith(I-) and current_entity is not None: current_entity[text] token current_entity[score] min(current_entity[score], score) else: if current_entity is not None: entities.append(current_entity) current_entity None start stride return entities这里的token是字粒度中文实体拼接没问题score取窗口内所有token的最小值保证低置信度的部分不被隐藏。重叠区域会识别出重复实体最后按文本和类型去重即可。注意这个简化版没有把原句中的偏移量加回来真正落地时还要把字符偏移量保存到实体里方便下游按位置高亮。5.2 用FastAPI暴露NER接口参数threshold怎么调训练好的模型放在Web服务里最直接的方式是FastAPI。先把pipeline打包成一个函数from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class NERRequest(BaseModel): text: str threshold: float 0.5 class EntityItem(BaseModel): text: str type: str score: float app.post(/ner, response_modeldict) def recognize(req: NERRequest): spans extract_entities_pipeline(req.text, thresholdreq.threshold) return {entities: [e.dict() for e in spans]}threshold是置信度过滤参数。经验上通用文本先设0.5如果业务上人名、地名要求高召回降到0.3如果要拿来入库、做下游精确匹配升到0.7以上。不同实体类型还可以分别设置阈值比如人名0.8、机构名0.6因为机构名识别难度明显更高。接口层建议加个最小长度过滤比如返回的实体少于2个字且不是单字人名大概率是噪声。部署在CPU机器上时为了吞吐可以用torch.no_grad()包住推理函数模型加载一次常驻内存如果并发量高把batch推理用上一个请求一个模型逐个forward很浪费GPU。常见做法是FastAPI侧做一个简单的队列或者直接用独立的推理服务但中小项目用FastAPI加进程内模型足够。6. 从F1到落地实体级别的正确验证方法前面的避坑章提到验证要看实体F1这里给出一个最小验证代码。把连续B/I/E合并成实体span用集合匹配计算precision和recalldef extract_spans(label_sequence, token_sequence): spans [] current None for i, label in enumerate(label_sequence): if label O: if current is not None: spans.append((.join(token_sequence[current[0]:i]), current[1])) current None elif label.startswith(B-): if current is not None: spans.append((.join(token_sequence[current[0]:i]), current[1])) current (i, label[2:]) elif label.startswith(E-): if current is not None: spans.append((.join(token_sequence[current[0]:i 1]), current[1])) current None elif label.startswith(I-) and current is not None: pass return spanspred和gold都生成spans集合两个集合取交集的实体数量记为true positive预测集合大小是predicted标注集合大小是actual然后precision tp / len(pred_spans)recall tp / len(gold_spans)F1 2 * precision * recall / (precision recall)。注意做集合匹配时不要只看文本相同类型也要相同人名“深圳”和地名“深圳”是两回事。数据里的重叠实体比如“中国计算机大会”是ORG整体里面又有“中国”是LOC两个span都合法这种重叠情况用集合匹配会漏掉一个。要处理重叠实体得切换到起止偏移的匹配给gold和pred分别记录(start, end, type)再用同一实体允许重叠、不允许多对一的规则。我习惯的做法是评估脚本里同时输出token级acc、实体级strict F1和宽松F1三个指标一起看strict卡边界宽松只看类型和位置重叠度。最后一轮经验每换一个数据集第一步看标签分布和实体长度分布第二步找三条最长实体和三条最短实体在验证集上人工过一遍第三步才决定要不要调整阈值和窗口大小。这个顺序帮我把好多次“F1还能看但业务上完全没法用”的模型及时拦下来。希望帮到你。本文还有配套的精品资源点击获取
返回列表