
简介面向自然语言处理初学者与需要落地中文实体抽取的开发者这份PDF文档围绕BERT预训练模型微调完成命名实体识别任务展开属于入门到实践的过渡型笔记。内容先界定地址、书籍、公司、游戏、政府、电影、姓名、组织、职位、场景共10类实体演示B-、I-前缀的标签构造与id2label、label2id数字映射再借助transformers加载bert-base-chinese分词器逐项说明input_ids、token_type_ids、attention_mask三个输入字段的含义并结合实例讲解分词后长度与原文不一致的原因及标签对齐处理思路最后给出模型微调、指标评估与新文本预测的完整流程。包内为1个PDF文件约545KB篇幅紧凑、代码与输出结果对照排版适合按步骤复现。该资源已有1245人学习可用于信息抽取、知识图谱构建前的实体识别练手也能作为课堂演示与项目起步的参考材料。1. 21 个标签、10 类实体这份 BERT 微调 NER 笔记解决的到底是什么问题上个月帮朋友处理一批纺织史资料要从几百段文字里把朝代、地名、人名、织物名抠出来做结构化。我先写了套正则加词典头两天还挺美第三天开始翻车——“半坡”既是地名又出现在图注编号里“仰韶文化”一半该算组织一半该算场景规则越加越长改一处崩三处。后来换成 BERT 预训练模型微调做命名实体识别才算把这条路走通。这份资料记录的就是这套流程先把 10 类实体展开成 21 个 BIO 标签再用BertTokenizer把中文句子拆成 token接着解决最麻烦的标签对齐——原文按字符标注、模型按 token 输出两边长度对不上get_token_label就是干这个的。最后拼成训练样本喂给BertForTokenClassification。适合两类人刚学完自然语言处理、想跑通第一个中文 NER 的以及模型能训起来但 F1 一直上不去、怀疑数据有问题的。下面按我复现时的顺序拆。2. 工程骨架搭建目录划分、依赖锁定与预训练权重落地这份资料一开始是一份单文件笔记代码全堆在一个.md里。真要跑第二遍、换个实体体系再训一次就得先把它拆成工程。我的拆法是按「数据 → 标签定义 → 对齐逻辑 → 训练 → 推理」五段切好处是标签对齐那部分能单独写测试不用每次都跑完整训练才验证。2.1 目录结构与文件职责bert-ner/ ├── data/ │ ├── text.txt # 每行一个字符空行分隔句子 │ └── char_label.txt # 与 text.txt 严格一一对应每行一个标签 ├── pretrained/ │ └── bert-base-chinese/ # 词表、config、pytorch_model.bin ├── src/ │ ├── labels.py # entities / label_names / id2label / label2id │ ├── data_utils.py # 读文件、get_token_label、make_sample │ ├── train.py # 训练入口 │ └── predict.py # 推理与 decode ├── outputs/ │ ├── checkpoint-xxx/ # 模型权重 │ └── label_map.json # 标签映射必须和权重一起存 └── requirements.txtlabel_map.json这个文件是我后来补的原始笔记里id2label是运行时算出来的一旦哪天改了entities的顺序又加载旧权重模型输出的 id 7 可能从B-name变成B-organization而且不报错只是结果全乱。这种错最难查因为 loss 曲线看着完全正常。2.2 依赖版本与安装python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # torch 按自己机器的 CUDA 版本装这里只锁大版本 pip install torch2.0 # transformers 和 tokenizers 是绑定的别单独升降某一个 pip install transformers4.36,4.46 seqeval1.2.2 scikit-learn tqdmseqeval很多人会漏装它是算实体级 F1 的不是按 token 算。transformers和tokenizers的版本耦合很紧我见过装完提示cant import PreTrainedTokenizer的八成是两个包版本对不上直接pip install -U transformers让它自己带依赖最省事。2.3 预训练权重落地与离线缓存import os from transformers import BertTokenizer, BertForTokenClassification MODEL_DIR ./pretrained/bert-base-chinese # 联网机器上第一次加载会把权重落到缓存目录 # 把整个目录打包拷到内网机器再把下面这行打开 os.environ[TRANSFORMERS_OFFLINE] 1 tokenizer BertTokenizer.from_pretrained(MODEL_DIR) model BertForTokenClassification.from_pretrained( MODEL_DIR, num_labels21, )from_pretrained传本地目录和传模型名字行为是一样的区别是前者不会去联网校验。目录里必须有vocab.txt、config.json、pytorch_model.bin三个文件少一个都会在加载阶段报错报错信息还算清楚看得懂就不慌。num_labels21这个数字要和label_names的长度严格相等。我第一次写的时候手滑写成 20漏了O模型照样能训只是最后一个标签永远预测不出来F1 卡在 0.6 上不去查了半天才发现是这里。3. 标签体系与分词器从 10 类实体到 21 个标签 id再到 token 序列标签体系是整个项目的地基。地基歪了后面 loss 降到 0.1 都是白搭。原始笔记里这段定义只有六行但它决定了后面所有数据结构长什么样值得单独拎出来讲透。3.1 从 entities 到 id2label三层映射一个都不能省# 定义实体 entities [address, book, company, game, government, movie, name, organization, position, scene] # 设置 labelO 每个实体的 B 和 I label_names [O] [B- x for x in entities] [I- x for x in entities] # 设置 label 对应的数字 id2label {i: label for i, label in enumerate(label_names)} label2id {v: k for k, v in id2label.items()}这里的三层结构是entities是业务语义层告诉你系统关心哪 10 类东西label_names是标注层把每类实体拆成「开始 B」和「内部 I」两个标签加上非实体的O一共 1 10 10 21 个id2label/label2id是模型层交叉熵只认整数。为什么必须是 BIO 而不是直接给 10 个类别加一个「无」因为相邻的两个同类型实体需要区分边界。比如「张三和李四」如果都用name标模型没法知道这是两个人还是一个四字人名用B-name I-name O B-name I-name就能切干净。中文里这种情况很常见尤其人名连写的时候。O放在第 0 位不是随便定的。它是样本里占比最高的标签一般 85% 以上放在 0 位配合后面的-100忽略位做 mask 和统计都方便。这个顺序一旦定下来就不能动动了旧权重就废了。3.2 BertTokenizer 吐出的三个张量from transformers import BertTokenizer model_name bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_name) text 世上或许有两个那么喜欢《爱丽斯梦游奇境》的人 tokenized_input tokenizer(text) print(tokenized_input[input_ids]) # [101, 686, 677, 2772, 6387, ..., 113, 102] print(tokenized_input[token_type_ids]) # [0, 0, 0, ...] print(tokenized_input[attention_mask]) # [1, 1, 1, ...]三个张量各有分工一个都不能少字段长度含义取值作用input_idstoken 序列长度0~21127文字 → 词表 id模型的真正输入token_type_ids同上全 0区分句子对里的第一句/第二句单句任务全 0attention_mask同上1 有效 / 0 填充告诉注意力机制哪些位置是 paddinginput_ids里的 101 是[CLS]102 是[SEP]100 是[UNK]。这三个是 BERT 的硬约定写死在词表里。单句 NER 任务里token_type_ids永远是全 0attention_mask在没有 padding 时永远是全 1看着没用但 batch 里一旦有长短不一的句子attention_mask就是防止模型把 padding 当成正文的关键。原文还演示了反查tokens tokenizer.convert_ids_to_tokens(tokenized_input[input_ids]) print(tokens) # [[CLS], 世, 上, 或, 许, 有, 两, 个, 人, # 并, 不, 那, 么, 喜, 欢, [UNK], 的, 原, 著, # 小, 说, 《, 爱, 丽, 斯, 梦, 游, 奇, 境, 》, # (, [SEP]]注意那个[UNK]。原句这里是某个词词表里没有被吞掉了。这个位置在标签对齐时是个麻烦点因为原文的字符还在token 却没了游标必须跳过去。3.3 token 序列比原文字长的三个原因对齐之所以是个难题根子在于「字符数 ≠ token 数」。原始笔记列了三条我补充一下实际操作中的观察第一首尾各加一个特殊符固定 2。[CLS]和[SEP]不承载任何实体语义标签一律给O。第二英文和数字会被切成 subword。charles在中文词表里大概率被切成char、##les两段或者干脆整词未登录变[UNK]。带##前缀的那个 token 表示「接在上一个 token 后面」算标签时要去掉##再比字符。第三tokenizer 做了基础归一化。BertTokenizer默认do_lower_caseTrue大写字母会被转小写全角字母数字会被转半角连续空白会被压缩。原始笔记里那句iter_text iter(text.lower())就是在补这个差异——文本游标走的是小写后的字符流否则一遇到大写字母就断言失败。还有个容易被忽略的点中文标点通常一个字一个 token但数字串「1984」是四个 token°、²这种符号可能直接[UNK]。所以对齐函数的健壮性不能建立在「一个汉字一个 token」的假设上得真的按分支处理。4. 标签对齐常见问题排查get_token_label 的四个分支和五种错位这一章是全篇最硬的部分。原始笔记里get_token_label大约 40 行我照着抄下来跑前后崩了五次每次报错信息都不一样。把踩过的坑和函数本身放在一起讲比分开讲有用。4.1 bio 数据读入把 text 和 char_label 拆成两个列表原始数据是两个 txt用空行分隔句子每行一个字符或一个标签def read_bio_file(path): 读 一行一个单元、空行分句 的标注文件 result [] current [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if line: current.append(line) else: if current: # 遇到空行一句结束 result.append(current) current [] if current: # 文件末尾没有空行的情况 result.append(current) return result texts [.join(chars) for chars in read_bio_file(data/text.txt)] char_labels read_bio_file(data/char_label.txt) assert len(texts) len(char_labels), 句数对不上 for t, lbs in zip(texts, char_labels): assert len(t) len(lbs), f字符数 {len(t)} 与标签数 {len(lbs)} 不等: {t[:20]}最后那个断言是我加的原始笔记没有。它能在数据读入阶段就把「标注漏字」的问题揪出来——人工标注时多敲一个空格、少标一个标点都会让两个文件长度错位而这种错误如果拖到对齐阶段才暴露报错位置和真实原因能差出十几行。注意text.txt是「一个字符一行」而不是「一句话一行」这是字符级标注的典型格式。如果你的数据是一句一行那read_bio_file要换个写法标签得用空格拆。4.2 get_token_label 的四种分支原始函数用游标推进的方式同时遍历 token 流和字符流我把它重排了一下加了断言和注释逻辑完全一致def _next(it_char, it_label, c, lb): 两个游标一起前进一步末尾返回 None 而不是抛异常 try: return next(it_char), next(it_label) except StopIteration: return c, lb def get_token_label(text, char_labels, tokenizer): tokens tokenizer.convert_ids_to_tokens(tokenizer(text)[input_ids]) it_tok iter(tokens) it_char iter(text.lower()) # tokenizer 默认 do_lower_case it_lab iter(char_labels) t next(it_tok) c next(it_char) lb next(it_lab) token_labels [] while True: if t [UNK]: # 词表外字符tokenizer 已经把它丢了只能沿用当前字符的标签 token_labels.append(lb) elif t in tokenizer.special_tokens_map.values(): # [CLS] [SEP] [PAD] 不承载实体语义一律 O token_labels.append(O) elif len(t) 1: # 单字符 token汉字、标点、单个数字直接对位 assert t c, f字符游标错位: token{t!r}, char{c!r} token_labels.append(lb) c, lb _next(it_char, it_lab, c, lb) else: # 多字符 token英文单词或 ##subword word t.replace(##, ) word_label O for _ in word: if lb ! O: word_label lb # 实体内部取最近的非 O 标签 c, lb _next(it_char, it_lab, c, lb) token_labels.append(word_label) try: t next(it_tok) except StopIteration: break if t in tokenizer.special_tokens_map.values(): continue # 特殊符不需要挪字符游标 # 关键一步把字符游标追到下一个 token 的首字符 while c ! t[0]: c, lb _next(it_char, it_lab, c, lb) assert len(token_labels) len(tokens), 对齐后长度必须等于 token 数 return token_labels四个分支各自的职责[UNK]分支最简单——字符在原文里存在但词表里没有tokenizer 用一个占位符顶替。这时候字符游标不能动标签直接取当前位置的然后靠后面的while c ! t[0]把游标推到下一个能对上的字符。特殊符分支直接给O。这里要排除[UNK]因为special_tokens_map.values()里也包含它判断顺序反了就会把未登录字符全标成O。单字符分支是对齐的主干assert t c是整个函数的守门员。它一旦触发说明前面的游标推错了越早崩越好。多字符分支处理英文和 subword。t.replace(##, )去掉子词前缀后逐个字符消费游标如果这个 token 对应的原始标签里有非O的就取那个非O的保证实体边界不会因为切词而丢掉。4.3 五种错位现象、原因、解决第一种assert t c在含全角符号的句子上必崩。现象是字符游标错位: token( , char这类。原因是BertTokenizer的基础分词会把全角括号、全角数字转成半角字符数没变但内容变了。解决是把输入文本先过一遍tokenizer.basic_tokenizer.tokenize看看实际会被改成什么或者更彻底的办法——换BertTokenizerFast用offset_mapping让 tokenizer 自己告诉你每个 token 对应原文哪一段游标逻辑整个可以删掉。第二种labels里出现label2id不认识的值直接KeyError。原始数据里出现了B-Apparel、I-Apparel而entities列表里根本没有apparel。这是「换了实体体系但标注文件没重标」的典型状态。解决分两步先把数据里实际出现的标签集合和label_names做差集打印出来人工确认哪些是废弃标签、哪些是漏定义的再决定是加进entities还是在读入时过滤掉。used {lb for lbs in char_labels for lb in lbs} print(数据里多出来的标签:, used - set(label_names)) print(定义了但没用上的标签:, set(label_names) - used)第三种跑完不报错但len(token_labels) ! len(tokens)。十有八九是某个英文字符串被切成了 subword而##前缀处理完之后游标少走了一步。我的做法是在函数外再包一层for i, (t, lbs) in enumerate(zip(texts, char_labels)): seq get_token_label(t, lbs, tokenizer) toks tokenizer.convert_ids_to_tokens(tokenizer(t)[input_ids]) if len(seq) ! len(toks): print(i, len(seq), len(toks), t[:30]) # 打印出问题的句子下标第四种对齐全对但训练集里 (O) 标签占了 92%。不是 bug是数据分布。字符级标注下大部分字都不是实体直接argmax全预测O就能拿到 92% 的 token 准确率。这个问题在评估阶段会误导人第 5 章细讲。第五种句子超过 512 token 被截断后labels没跟着截。tokenizer(..., truncationTrue, max_length512)只截input_idslabels是自己拼进去的长度还是原来的。丢进模型那一刻报 shape 不匹配。解决是先把labels按input_ids的实际长度切一刀再赋值。4.4 make_sample 与批处理 collator对齐函数跑通之后拼样本就三行def make_sample(text, char_labels, tokenizer, label2id): sample tokenizer(text, truncationTrue, max_length256) token_labels get_token_label(text, char_labels, tokenizer) # 截断后 labels 必须同步截断 sample[labels] [label2id[x] for x in token_labels][:len(sample[input_ids])] return sample注意这里max_length256是个经验值。中文 NER 的句子大多不长256 能覆盖 95% 以上比 512 省显存。如果你的数据里动辄上千字那要改成滑窗切分而不是简单截断不然边界实体全丢。批处理用现成的from transformers import DataCollatorForTokenClassification collator DataCollatorForTokenClassification(tokenizertokenizer, paddingTrue)它会自动做两件事input_ids和attention_mask补 0 对齐labels补-100。这个-100很关键下一章展开。5. 训练与推理链路模型头、损失口径、评估指标与 decode数据准备好之后训练本身反而是最短的一段。但「能训起来」和「训出来能用」是两回事差别全在损失口径和评估方式上。5.1 BertForTokenClassification 与那个 -100from transformers import BertForTokenClassification model BertForTokenClassification.from_pretrained( ./pretrained/bert-base-chinese, num_labelslen(label_names), id2labelid2label, label2idlabel2id, )这个模型的结构是 BERT 编码器 一个线性分类头输出维度[batch, seq_len, num_labels]。每个 token 位置独立分类这就是所谓的 token classification。关键在于损失函数。PyTorch 的CrossEntropyLoss默认ignore_index-100也就是标签为 -100 的位置不计算损失。所以[CLS]、[SEP]、padding 位置应该给-100让它们不参与训练但O的 id 是 0不是 -100所以O是正常参与训练的。原始笔记里所有位置都赋了label2id的结果0~20特殊符也是O。这样模型会学到「[CLS]一定是 O」这种没用的规律虽然不影响收敛但会让 loss 数值偏低看着很乐观。我一般加一步IGNORE -100 for i, tok in enumerate(tokens): if tok in tokenizer.special_tokens_map.values(): sample[labels][i] IGNORE5.2 训练循环与超参from transformers import TrainingArguments, Trainer args TrainingArguments( output_dir./outputs, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate3e-5, # BERT 微调常用区间 2e-5 ~ 5e-5 num_train_epochs4, warmup_ratio0.1, weight_decay0.01, logging_steps50, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1, ) trainer Trainer( modelmodel, argsargs, train_datasettrain_dataset, eval_dataseteval_dataset, data_collatorcollator, compute_metricscompute_metrics, ) trainer.train()几个参数的实际含义learning_rate3e-5是微调预训练模型的安全区调到 1e-4 常常训崩调到 1e-6 又基本学不动warmup_ratio0.1让前 10% 的 step 学习率线性爬升避免一开始就把预训练权重冲坏load_best_model_at_endTrue配合metric_for_best_model才能在训练结束时自动回到 F1 最高的那个 checkpoint不然拿到的是最后一轮而最后一轮经常已经过拟合了。数据量小的时候几百句num_train_epochs4已经足够再多就是记答案。判断过拟合看两个数训练 loss 还在降、验证 F1 开始掉这时候停。5.3 评估token 级准确率会骗人from seqeval.metrics import classification_report, f1_score def compute_metrics(eval_pred): logits, labels eval_pred preds logits.argmax(-1) # -100 的位置要剔掉否则 id2label[-100] 直接崩 true_seqs, pred_seqs [], [] for p_row, l_row in zip(preds, labels): t_seq, p_seq [], [] for p, l in zip(p_row, l_row): if l -100: continue t_seq.append(id2label[l]) p_seq.append(id2label[p]) true_seqs.append(t_seq) pred_seqs.append(p_seq) return {f1: f1_score(true_seqs, pred_seqs)}seqeval算的是实体级 F1先把B-name I-name还原成一个完整的「张三」再比对预测的实体和真实实体是否完全一致。一个实体里错一个字整个实体算错。这个口径比 token 级严格得多也更接近业务实际——你从文本里抽一个公司名抽出「北京科技」而真实是「北京科技有限公司」业务上就是错的token 准确率却会给到 80%。我踩过一次token 准确率 0.97高兴了半天换成 seqeval 一跑 F1 只有 0.41。原因是模型把长实体的边界学歪了B和I经常互换但每个字归到「某个实体」还是对的。所以从第一个实验开始就该用实体级指标别给自己留念想。5.4 推理与 decodeimport torch def extract_entities(text, model, tokenizer, id2label, devicecpu): model.eval() enc tokenizer(text, return_tensorspt, truncationTrue, max_length256) enc {k: v.to(device) for k, v in enc.items()} with torch.no_grad(): logits model(**enc).logits preds logits.argmax(-1)[0].tolist() tokens tokenizer.convert_ids_to_tokens(enc[input_ids][0]) entities, buf, buf_type [], , None for tok, pid in zip(tokens, preds): if tok in tokenizer.special_tokens_map.values(): continue label id2label[pid] if label.startswith(B-): if buf: # 上一个实体收尾 entities.append((buf_type, buf)) buf, buf_type tok.replace(##, ), label[2:] elif label.startswith(I-) and buf and label[2:] buf_type: buf tok.replace(##, ) else: if buf: entities.append((buf_type, buf)) buf, buf_type , None if buf: entities.append((buf_type, buf)) return entitiesdecode 里最容易漏的是I-的类型校验。如果上一个实体是B-name当前 token 预测成I-company说明模型自己都矛盾了这时候应该断开重新开始而不是硬接上去。原始笔记没写到这一步但真上线时不加这个判断会出现「张公司」这种跨类型拼接的鬼东西。6. 二次开发换标签体系之后怎么保证模型没被换坏改这个项目最常见的需求就是换实体体系——从 10 类砍到 5 类或者加一个time。改起来就几行坑全在后面。第一件事entities一改label_names的顺序就变了id2label跟着变旧 checkpoint 直接作废。所以每次训练完我都把映射和权重存在一起import json, os save_dir ./outputs/best os.makedirs(save_dir, exist_okTrue) model.save_pretrained(save_dir) tokenizer.save_pretrained(save_dir) json.dump({id2label: id2label, label2id: label2id}, open(f{save_dir}/label_map.json, w), ensure_asciiFalse, indent2)加载的时候先读label_map.json再比对model.config.num_labels不一致就直接抛异常。这个检查救过我一次——两次实验用了同一份输出目录第二次的模型配第一次的标签表推理结果看着像模像样其实是错位的。第二件事换底座模型时 tokenizer 必须跟着换。bert-base-chinese换成领域预训练模型词表大概率不一样[UNK]的比例、subword 的切法全变get_token_label的对齐结果需要重新验证一遍。验证方法很简单抽 20 句把对齐后的(token, label)打出来人工看一遍重点看英文、数字、全角符号那几类。第三件事建一个小规模的 golden set。我从标注数据里挑 30 条覆盖所有实体类型的句子人工核对期望的实体列表存成 JSON。每次改代码、改标签、换模型跑一遍对比实体级 F1掉超过 3 个点就回滚。这个习惯是从一次「改了个 padding 逻辑F1 从 0.82 掉到 0.55」之后养成的当时没有任何报错只是悄悄变差。如果只能记一个技巧那就是把 4.2 节那套手写游标对齐整个删掉换BertTokenizerFast的offset_mappingfast_tok BertTokenizerFast.from_pretrained(./pretrained/bert-base-chinese) enc fast_tok(张三在北京工作, return_offsets_mappingTrue) # offset_mapping: [(0,0), (0,1), (1,2), (2,3), (3,4), (4,5), (5,6), (0,0)] # 每个 token 直接告诉你对应原文的 [start, end)字符级标签切片即可一行采样换四十行游标推进全角半角、大小写、subword 全都不用管[UNK]也能靠 offset 找回来。从那以后我每次碰字符级对齐都强制先用 fast tokenizer 看 offset 对不对得上再决定要不要写自定义逻辑。希望这套拆法能帮你少走两晚弯路。本文还有配套的精品资源点击获取