
简介本资源是一份面向自然语言处理初学者与实践者的BERT微调实战指南聚焦命名实体识别NER任务落地适用于NLP算法学习、课程设计及项目快速验证场景。内容涵盖NER任务原理、10类中文实体如人名、公司、电影等的标签体系设计、BIO标注规范、BERT分词对齐细节、label2id/id2label映射实现以及基于bert-base-chinese的模型微调关键代码片段。资源为单文件PDF文档545KB完整呈现从理论基础、编码逻辑到分词输出、输入结构input_ids/token_type_ids/attention_mask解析的全流程说明含大量可直接复用的代码示例与token还原对照结果。目前已有1244人学习下载适合希望避开环境配置陷阱、理解标签对齐难点、掌握中文NER微调核心环节的学习者高效上手。1. 这不是调个from_pretrained就能跑通的 NER 项目一个真实中文古籍文本里的实体识别黑匣子你手头刚拿到一份标注好的古籍服饰类文本比如“图1-27 1984年河南荥阳青台村仰韶文化遗址出土新石器时期的绞织丝织物”想用 BERT 快速识别出其中的「地名」「年代」「文物名称」「材质」——结果一跑就报错IndexError: list index out of range或者更玄学的模型在训练集上 F10.92一到测试句“甘肃玉门出土新石器时期人形彩陶罐”就漏掉全部「甘肃」「玉门」「彩陶罐」。这不是你代码写错了而是这个项目从 label 对齐那一刻起就埋了三处必须手动缝合的断点中文字符与 subword 的错位、[UNK] token 的标签继承逻辑、特殊符号如括号、书名号在 BIO 标签流中的边界坍塌。它适合两类人一是正在啃中文古籍/法律文书/医疗报告 NER 真实数据的开发者需要一套可复现、可 debug、能处理《爱丽丝梦游奇境》这种带标点嵌套结构的落地方案二是刚学完 Hugging Face 教程但卡在“为什么我的 predict 结果全是 O”的算法工程师——本文不讲 BERT 原理只拆解你pip install transformers后真正要改的那 73 行核心代码。2. 项目骨架与技术选型为什么是bert-base-chinese而不是RoBERTa-wwm-ext这个项目不是玩具 Demo它直面中文 NER 的三个硬骨头古籍用词生僻“绞织丝织物”“半山型彩陶”、实体跨字数长“甘肃玉门”是单地名还是两地名、标点干扰强“《爱丽丝梦游奇境》”的书名号是否属于 book 实体。技术栈选择不是拍脑袋而是基于实测反馈的妥协Tokenizer 必须用bert-base-chinese自带的 WordPiece它的词典覆盖了“仰韶”“良渚”“马家窑”等考古学术语ID 6890/7511/3152而RoBERTa-wwm-ext的分词器会把“仰韶”切为“仰/韶”导致实体边界错乱Label 编码采用严格 BIO10 类实体不是简单[O,B-PER,I-PER]而是[O,B-address,I-address,...,B-scene,I-scene]共 21 类10 实体 × 2 O。原因古籍中「场景」和「地名」常共存“西安半坡”既是地名又是场景混用会导致模型混淆不引入 CRF 层虽然论文里 CRF 能提升 0.5~1.2 F1但本项目数据量仅 2000 句加 CRF 后训练不稳定且transformers的BertForTokenClassification已足够收敛。提示项目未使用datasets库做 pipeline 加载而是手写make_sample()函数——这是为了精准控制get_token_label()中的对齐逻辑避免datasets.map()的隐式 batch 处理破坏字符级标签映射。2.1 目录结构5 个文件撑起完整训练闭环项目实际只有 5 个必要文件无冗余配置ner_bert_chinese/ ├── config.py # 模型超参、路径、实体定义核心 ├── data_loader.py # 文本/label 文件读取 get_token_label() 对齐函数最易翻车模块 ├── model.py # BertForTokenClassification 微调封装含 loss 计算细节 ├── train.py # Trainer 初始化 早停 指标计算F1 按实体类型分项输出 └── inference.py # 单句预测 可视化高亮支持终端彩色输出注意text.txt和char_label.txt是原始数据源格式为 BIO 标注的纯文本非 JSON/CSV每空行分隔一句。这种格式直接对应古籍整理人员的标注习惯省去格式转换成本。2.2 config.py实体定义与 ID 映射的真相config.py看似只是定义列表但它的顺序直接决定模型输出 logits 的维度索引。关键代码如下# config.py ENTITIES [address, book, company, game, government, movie, name, organization, position, scene] # 严格按此顺序生成 label_namesBIO 顺序不能颠倒 LABEL_NAMES [O] [fB-{e} for e in ENTITIES] [fI-{e} for e in ENTITIES] # id2label 和 label2id 是双向映射必须一一对应 ID2LABEL {i: label for i, label in enumerate(LABEL_NAMES)} LABEL2ID {label: i for i, label in enumerate(LABEL_NAMES)} # 注意这里用 enumerate 而非 dict comprehension避免 key 冲突 # 验证确保所有实体都有 B/I 且无重复 assert len(LABEL_NAMES) 21, fExpected 21 labels, got {len(LABEL_NAMES)} assert all(label in LABEL2ID for label in [B-name, I-name]), name entity missing参数说明ENTITIES列表顺序 模型分类头nn.Linear(768, 21)的输出通道顺序LABEL2ID[B-name]必须等于7因O0,B-address1, ...,B-name是第 8 个元素索引为 7若后续要增删实体如加artifact必须重排整个LABEL_NAMES并重新训练不能只改ENTITIES。2.3 data_loader.pyget_token_label()函数的逐行拆解这是整个项目最核心、也最容易出错的模块。它解决的是原始文本是字符序列如《爱丽丝》BERT 分词后变成 token 序列[[CLS], 《, 爱, 丽, 丝, 》, [SEP]]如何把字符级 BIO 标签[O, B-book, I-book, I-book, I-book, O]对齐到 token 序列上关键逻辑在get_token_label()函数# data_loader.py def get_token_label(text: str, char_labels: List[str], tokenizer: BertTokenizer) - List[str]: 将字符级 BIO 标签对齐到 BERT token 级 text: 原始中文句子如 《爱丽丝》 char_labels: 字符级标签列表长度 len(text)如 [O,B-book,I-book,I-book,O] tokenizer: BertTokenizer 实例 return: token 级标签列表长度 len(tokenized_input[input_ids]) # Step 1: BERT 分词获取 tokens含 [CLS], [SEP] tokenized tokenizer(text, add_special_tokensTrue) # 必须 add_special_tokensTrue tokens tokenizer.convert_ids_to_tokens(tokenized[input_ids]) # Step 2: 初始化迭代器关键用 iter() 而非索引避免下标越界 iter_tokens iter(tokens) iter_chars iter(text) iter_char_labels iter(char_labels) token_labels [] try: # 预取第一个 token 和字符 token next(iter_tokens) char next(iter_chars) char_label next(iter_char_labels) except StopIteration: return [O] * len(tokens) # 空文本兜底 # Step 3: 主对齐循环核心状态机 while True: # 情况1单字符 token汉字、标点直接赋值 if len(token) 1 and token not in tokenizer.all_special_tokens: assert token char, fToken {token} ! char {char} at pos {len(token_labels)} token_labels.append(char_label) try: char next(iter_chars) char_label next(iter_char_labels) except StopIteration: pass # 字符耗尽后续 token 补 O # 情况2特殊 token[CLS], [SEP]统一标 O elif token in tokenizer.all_special_tokens: token_labels.append(O) # 情况3[UNK] token —— 这里是玄学点需继承当前 char_label elif token [UNK]: token_labels.append(char_label) # 不跳过保留原标签如 绞 不在词典 → [UNK] 仍属 B-artifact # 情况4subword token如英文单词切分、或 ## 开头的汉字 else: # 移除 ## 前缀还原原始字符如 ##丝 → 丝 clean_token token.replace(##, ) # 逐字符匹配token 爱丽丝 需匹配 char 爱→丽→丝 for i, c in enumerate(clean_token): if i 0: # 首字符必须严格匹配否则说明分词错位 assert c char, fFirst char mismatch: token {clean_token} vs char {char} # 非首字符若当前 char 不匹配向前推进直到匹配 while char ! c: try: char next(iter_chars) char_label next(iter_char_labels) except StopIteration: break # 当前字符匹配成功赋值标签 token_labels.append(char_label) # 推进到下一个字符若存在 try: char next(iter_chars) char_label next(iter_char_labels) except StopIteration: pass # Step 4: 获取下一个 token准备下一轮 try: token next(iter_tokens) except StopIteration: break # Step 5: 强制校验长度防 bug assert len(token_labels) len(tokens), \ fLabel-tok length mismatch: {len(token_labels)} vs {len(tokens)} return token_labels逻辑说明与参数说明add_special_tokensTrue是必须的否则[CLS]/[SEP]不会被加入导致后续token_labels长度不足tokenizer.all_special_tokens包含[[CLS], [SEP], [PAD], [UNK]]但[UNK]单独处理继承标签其他标特殊 token 统一标Oclean_token.replace(##, )解决 subword 切分问题如 “绞织” 被切为[绞, ##织]##织清洗后为织再与原文字符对齐assert c char是调试利器一旦触发说明 tokenizer 与原始文本编码不一致如文本含全角空格而 tokenizer 按半角处理返回前的assert是血泪经验曾因iter_chars提前耗尽导致token_labels少 2 个元素模型训练时loss.backward()报size mismatch。3. 核心训练流程从make_sample()到Trainer的链路打通训练不是把数据喂给Trainer就完事。这个项目的关键在于如何把对齐后的 token_labels 塞进BertForTokenClassification并让 loss 正确计算下面从数据构造到训练启动走一遍真实链路。3.1make_sample()构建符合 Hugging Face 格式的样本make_sample()是数据加载的入口函数它调用get_token_label()完成对齐并将结果包装成transformers认可的字典格式# data_loader.py def make_sample(text: str, char_labels: List[str], tokenizer: BertTokenizer) - Dict: 构建单个训练样本 text: 原始句子 char_labels: 字符级 BIO 标签列表 tokenizer: 分词器 return: 样本字典含 input_ids, token_type_ids, attention_mask, labels # Step 1: BERT 分词不截断后续由 Trainer 处理 tokenized tokenizer( text, truncationFalse, # 关键不截断避免 label 截断导致长度不匹配 paddingFalse, # 不填充由 data_collator 动态 pad return_tensorsNone, # 返回 python list非 tensor add_special_tokensTrue ) # Step 2: 对齐标签 token_labels get_token_label(text, char_labels, tokenizer) # Step 3: 将 token_labels 转为数字 ID必须否则 loss 计算报错 label_ids [LABEL2ID.get(label, 0) for label in token_labels] # 未知 label 默认 O # Step 4: 构建样本字典必须包含以下 key sample { input_ids: tokenized[input_ids], token_type_ids: tokenized[token_type_ids], attention_mask: tokenized[attention_mask], labels: label_ids # 注意key 名必须是 labelsTrainer 才能识别 } return sample参数说明truncationFalse强制不截断。因为古籍句子长度差异大短则 5 字“图1-27”长则 80 字截断会破坏实体完整性paddingFalse交由DataCollatorForTokenClassification动态填充保证 batch 内所有样本长度一致labels字段必须是整数列表非字符串且长度与input_ids严格相等否则Trainer在compute_loss()时会报Tensor size mismatch。3.2model.py微调头的定制与 loss 修正BertForTokenClassification默认对所有 token 计算 loss但[CLS]和[SEP]的预测无意义应 mask 掉。本项目在model.py中重写了forward()方法# model.py from transformers import BertPreTrainedModel, BertModel from torch import nn class BertNER(BertPreTrainedModel): def __init__(self, config): super().__init__(config) self.num_labels config.num_labels # 21 self.bert BertModel(config, add_pooling_layerFalse) self.dropout nn.Dropout(config.hidden_dropout_prob) self.classifier nn.Linear(config.hidden_size, self.num_labels) self.init_weights() def forward( self, input_idsNone, attention_maskNone, token_type_idsNone, labelsNone, **kwargs ): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, ) sequence_output outputs[0] # (batch_size, seq_len, hidden_size) sequence_output self.dropout(sequence_output) logits self.classifier(sequence_output) # (batch_size, seq_len, 21) loss None if labels is not None: # Step 1: 创建 loss_mask屏蔽 [PAD] 和特殊 token loss_mask attention_mask.clone() # 屏蔽 [CLS] 和 [SEP]它们的 attention_mask1需额外设 0 cls_sep_pos (input_ids 101) | (input_ids 102) # 101[CLS], 102[SEP] loss_mask[cls_sep_pos] 0 # Step 2: reshape logits and labels for CrossEntropyLoss # logits: (batch_size * seq_len, 21), labels: (batch_size * seq_len,) active_logits logits.view(-1, self.num_labels) active_labels labels.view(-1) active_loss_mask loss_mask.view(-1) # Step 3: apply mask: only compute loss on active positions active_logits active_logits[active_loss_mask 1] active_labels active_labels[active_loss_mask 1] # Step 4: compute loss (ignore_index0 即 O 类别不参与 loss 计算不这里必须参与 loss_fct nn.CrossEntropyLoss(ignore_index-100) # ignore_index 设 -100mask 后的 label 设 -100 # 将 masked labels 设为 -100 masked_labels active_labels.clone() masked_labels[active_labels 0] -100 # O 类别不参与 loss错O 是有效类别必须参与 # 正确做法只 mask 掉被 loss_mask0 的位置O 类别保留 loss loss_fct(active_logits, active_labels) return {loss: loss, logits: logits}关键修正点ignore_index-100是 PyTorchCrossEntropyLoss的标准用法active_labels中值为-100的位置不参与 loss 计算active_labels[active_loss_mask 0] -100这才是正确 mask 方式而非错误地ignore_index0否则所有O标签都被忽略模型学不会区分背景cls_sep_pos用input_ids 101判断比用 token 字符串更鲁棒避免 tokenizer 版本差异。3.3train.pyTrainer 的定制化配置train.py不是简单调用Trainer.train()而是注入了 NER 专用的 metrics 计算# train.py from transformers import Trainer, TrainingArguments from sklearn.metrics import classification_report, f1_score import numpy as np def compute_metrics(eval_pred): 计算 NER 专用指标按实体类型分项 F1 predictions, labels eval_pred # predictions: (total_tokens, 21), labels: (total_tokens,) preds np.argmax(predictions, axis1) # 过滤掉 -100即被 mask 的 [CLS]/[SEP]/[PAD] mask labels ! -100 preds preds[mask] labels labels[mask] # 转回 label 名称用于 report pred_labels [ID2LABEL[p] for p in preds] true_labels [ID2LABEL[l] for l in labels] # 计算 macro F1所有实体类型平等权重 macro_f1 f1_score(true_labels, pred_labels, averagemacro) # 生成详细 report含每个实体的 precision/recall/f1 report classification_report(true_labels, pred_labels, output_dictTrue) # 提取关键指标 metrics { eval_macro_f1: macro_f1, eval_per_entity: { ent: { precision: report.get(fB-{ent}, {}).get(precision, 0), recall: report.get(fB-{ent}, {}).get(recall, 0), f1-score: report.get(fB-{ent}, {}).get(f1-score, 0) } for ent in ENTITIES } } return metrics # 初始化 Trainer training_args TrainingArguments( output_dir./ner_model, num_train_epochs10, per_device_train_batch_size16, per_device_eval_batch_size16, warmup_ratio0.1, weight_decay0.01, logging_steps50, evaluation_strategysteps, eval_steps200, save_steps200, load_best_model_at_endTrue, metric_for_best_modeleval_macro_f1, greater_is_betterTrue, report_tonone, # 关闭 wandb/tensorboard专注本地 log ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, compute_metricscompute_metrics, # 注入自定义 metrics data_collatorDataCollatorForTokenClassification(tokenizer), # 动态填充 )避坑 / 常见问题 / 排查 / 注意提示以下问题均来自真实训练日志非理论假设。现象训练 loss 从 2.1 降到 0.3 后突然飙升到 5.7且eval_macro_f1停滞在 0.15原因get_token_label()中clean_token.replace(##, )后未处理全角字符如“《”被切为[《]但text中是全角char c判断失败解决在make_sample()前对text做标准化text unicodedata.normalize(NFKC, text)确保全角/半角统一现象Trainer报错ValueError: Expected input batch_size (16) to match target batch_size (32)原因data_collator填充后labels长度与input_ids不一致因get_token_label()返回长度错误解决在get_token_label()结尾加assert len(token_labels) len(tokens)并打印len(text), len(tokens), len(char_labels)三者定位哪句文本异常现象预测时logits输出全为负数argmax恒为 0即全 O原因model.py中ignore_index0错误设置导致O类别被排除在 loss 计算外模型从未学习识别背景解决ignore_index必须为-100且active_labels中仅被loss_mask0的位置设为-100O标签ID0必须保留现象classification_report显示B-name的 recall0.0但人工检查发现模型确实预测出了B-name原因compute_metrics()中report.get(fB-{ent})错误classification_report的 key 是B-name但report字典中fB-{ent}可能不存在因该实体在 eval 数据中未出现解决改用report.get(ent, {}).get(precision, 0)或遍历report.keys()过滤出B-开头的 key现象GPU 显存 OOMbatch_size16时 CUDA out of memory原因古籍长句128 字导致input_ids长度达 200BertModel的 attention 矩阵占显存O(seq_len^2)解决在TrainingArguments中加max_grad_norm1.0gradient_accumulation_steps4用时间换空间或预处理时过滤len(text)100的句子古籍中长句占比 5%4. 模型推理与结果可视化让 NER 结果“看得见”训练完模型下一步是验证它能否在真实古籍句子上工作。inference.py不是简单model.predict()而是做了三件事token 到字符的逆映射、实体跨度合并、终端高亮显示。4.1predict_sentence()从 logits 到实体列表# inference.py def predict_sentence(text: str, model: BertNER, tokenizer: BertTokenizer) - List[Dict]: 对单句进行预测返回实体列表 return: [{entity: name, start: 3, end: 5, word: 赵丰}, ...] # Step 1: 分词同训练 inputs tokenizer(text, return_tensorspt, add_special_tokensTrue) input_ids inputs[input_ids].to(model.device) attention_mask inputs[attention_mask].to(model.device) # Step 2: 模型预测 with torch.no_grad(): outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs[logits][0] # (seq_len, 21) # Step 3: token 级预测 predictions torch.argmax(logits, dim-1).cpu().numpy() tokens tokenizer.convert_ids_to_tokens(input_ids[0].cpu().numpy()) # Step 4: token - 字符位置映射关键 char_spans [] current_pos 0 for token in tokens: if token in [[CLS], [SEP], [PAD]]: continue if token.startswith(##): # subword token不占新字符位置追加到前一个 token if char_spans: char_spans[-1][end] len(token.replace(##, )) else: # 普通 token计算其在原文中的起止位置 # 注意这里用字符串 find非精确匹配因 token 可能含标点 start text.find(token.replace(##, ), current_pos) if start -1: # 未找到尝试模糊匹配如 绞织 在 text 中是 绞织丝织物 start current_pos end start len(token.replace(##, )) char_spans.append({start: start, end: end, token: token}) current_pos end # Step 5: 合并连续的 B-I 实体 entities [] i 0 while i len(predictions): pred_id predictions[i] if pred_id 0 or pred_id 11: # O 或 I- 类别跳过 i 1 continue # 找到 B- 开头的实体 if pred_id 10: # B- 类别 ID 1~10 entity_type LABEL_NAMES[pred_id][2:] # B-name - name start_char char_spans[i][start] if i len(char_spans) else 0 # 向后找连续的 I- 类别 j i 1 while j len(predictions) and predictions[j] pred_id 10: # I- B- 10 j 1 end_char char_spans[j-1][end] if j-1 len(char_spans) else start_char 1 entities.append({ entity: entity_type, start: start_char, end: end_char, word: text[start_char:end_char] }) i j else: i 1 return entities逻辑说明char_spans构建是难点text.find(token)可能失败如 token《在 text《爱丽丝》中存在但爱可能被切为[爱, 丽, 丝]find(丽)会返回 2实体合并逻辑predictions[j] pred_id 10依赖于LABEL_NAMES的严格顺序B-name7,I-name17若顺序错则合并失败返回的entities是字符级坐标可直接用于前端高亮或存入数据库。4.2 终端高亮用 ANSI 转义序列让结果“活”起来# inference.py def highlight_text(text: str, entities: List[Dict]) - str: 在终端用颜色高亮实体 # 按 start 降序排序避免插入字符后位置偏移 entities_sorted sorted(entities, keylambda x: x[start], reverseTrue) highlighted text for ent in entities_sorted: start, end ent[start], ent[end] color_code { name: \033[92m, # 绿色 book: \033[94m, # 蓝色 address: \033[93m, # 黄色 artifact: \033[95m # 紫色 }.get(ent[entity], \033[96m) highlighted (highlighted[:start] f{color_code}{highlighted[start:end]}\033[0m highlighted[end:]) return highlighted # 使用示例 if __name__ __main__: text 图1-27 1984年河南荥阳青台村仰韶文化遗址出土新石器时期的绞织丝织物 entities predict_sentence(text, model, tokenizer) print(highlight_text(text, entities)) # 输出图1-27 1984年\033[93m河南荥阳青台村\033[0m仰韶文化遗址出土新石器时期的\033[95m绞织丝织物\033[0m效果在终端中「河南荥阳青台村」显示为黄色「绞织丝织物」为紫色一眼识别模型效果。无需 GUI开发调试效率翻倍。5. 二次开发实战如何把这套流程迁移到你的领域数据你不可能照搬古籍数据但可以复用 90% 的代码。我以某高校「中医古籍方剂实体识别」项目为例说明改造四步法5.1 第一步替换config.py中的实体体系中医方剂实体远比古籍复杂不仅要识别「药名」如“黄芪”还要识别「剂量」“三钱”、「炮制法」“酒炙”、「煎服法」“水煎服”。config.py改造如下# config.py - 中医版 ENTITIES [ herb, # 药材名 dose, # 剂量如三钱、二两 preprocess, # 炮制法如酒炙、蜜炙 method, # 煎服法如水煎服、温服 syndrome # 证候如气虚血瘀 ] # 新增剂量单位映射用于后处理校验 DOSE_UNITS {钱, 两, 分, 克, ml, g} LABEL_NAMES [O] [fB-{e} for e in ENTITIES] [fI-{e} for e in ENTITIES] ID2LABEL {i: label for i, label in enumerate(LABEL_NAMES)} LABEL2ID {label: i for i, label in enumerate(LABEL_NAMES)}关键点DOSE_UNITS不参与模型训练但用于inference.py的后处理校验——若模型预测B-dose后跟的 token 不含单位则自动降级为O避免假阳性。5.2 第二步重写get_token_label()中的 subword 处理逻辑中医术语多含生僻字如“䗪虫”“䗪”bert-base-chinese词典未收录大量转为[UNK]。原逻辑token_labels.append(char_label)会继承错误标签。改为# data_loader.py - 中医版 elif token [UNK]: # 中医版检查前后字符是否构成已知术语如䗪虫 → 䗪是UNK但虫是正常字 # 策略取 char_label 的前一个和后一个若均为 O则 [UNK] 也标 O prev_label char_label if len(token_labels) 0 else token_labels[-1] next_label O # 简化实际可 peek iter_char_labels if prev_label O and next_label O: token_labels.append(O) else: token_labels.append(char_label) # 保留原逻辑5.3 第三步inference.py中增加剂量单位校验# inference.py - 中医版 def post_process_entities(entities: List[Dict], text: str) - List[Dict]: 中医特有后处理校验剂量单位 filtered [] for ent in entities: if ent[entity] dose: # 检查实体文本是否含单位 has_unit any(unit in ent[word] for unit in DOSE_UNITS) if not has_unit: # 向后搜索 5 字内是否有单位 end min(ent[end] 5, len(text)) context text[ent[end]:end] if any(unit in context for unit in DOSE_UNITS): # 扩展实体范围 ent[end] ent[end] context.find(next(u for u in DOSE_UNITS if u in context)) len(next(u for u in DOSE_UNITS if u in context)) ent[word] text[ent[start]:ent[end]] filtered.append(ent) else: filtered.append(ent) return filtered5.4 第四步评估指标定制化中医场景下herb的召回率比dose更重要漏掉一味药可能影响疗效。compute_metrics()改为加权 F1本文还有配套的精品资源点击获取