简介:面向自然语言处理初学者与需要落地中文实体抽取的开发者,这份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 "torch>=2.0" # transformers 和 tokenizers 是绑定的,别单独升降某一个 pip install "transformers>=4.36,<4.46" "seqeval==1.2.2" "scikit-learn" "tqdm"seqeval很多人会漏装,它是算实体级 F1 的,不是按 token 算。transformers和tokenizers的版本耦合很紧,我见过装完提示can't 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_labels=21, )from_pretrained传本地目录和传模型名字行为是一样的,区别是前者不会去联网校验。目录里必须有vocab.txt、config.json、pytorch_model.bin三个文件,少一个都会在加载阶段报错,报错信息还算清楚,看得懂就不慌。
num_labels=21这个数字要和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'] # 设置 label:O + 每个实体的 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_ids | token 序列长度 | 0~21127 | 文字 → 词表 id,模型的真正输入 |
token_type_ids | 同上 | 全 0 | 区分句子对里的第一句/第二句,单句任务全 0 |
attention_mask | 同上 | 1 有效 / 0 填充 | 告诉注意力机制哪些位置是 padding |
input_ids里的 101 是[CLS],102 是[SEP],100 是[UNK]。这三个是 BERT 的硬约定,写死在词表里。单句 NER 任务里token_type_ids永远是全 0,attention_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_case=True,大写字母会被转小写;全角字母数字会被转半角;连续空白会被压缩。原始笔记里那句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", encoding="utf-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(..., truncation=True, max_length=512)只截input_ids,labels是自己拼进去的,长度还是原来的。丢进模型那一刻报 shape 不匹配。解决是先把labels按input_ids的实际长度切一刀再赋值。
4.4 make_sample 与批处理 collator
对齐函数跑通之后,拼样本就三行:
def make_sample(text, char_labels, tokenizer, label2id): sample = tokenizer(text, truncation=True, max_length=256) 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_length=256是个经验值。中文 NER 的句子大多不长,256 能覆盖 95% 以上,比 512 省显存。如果你的数据里动辄上千字,那要改成滑窗切分而不是简单截断,不然边界实体全丢。
批处理用现成的:
from transformers import DataCollatorForTokenClassification collator = DataCollatorForTokenClassification(tokenizer=tokenizer, padding=True)它会自动做两件事:input_ids和attention_mask补 0 对齐;labels补-100。这个-100很关键,下一章展开。
5. 训练与推理链路:模型头、损失口径、评估指标与 decode
数据准备好之后,训练本身反而是最短的一段。但「能训起来」和「训出来能用」是两回事,差别全在损失口径和评估方式上。
5.1 BertForTokenClassification 与那个 -100
from transformers import BertForTokenClassification model = BertForTokenClassification.from_pretrained( "./pretrained/bert-base-chinese", num_labels=len(label_names), id2label=id2label, label2id=label2id, )这个模型的结构是 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_size=16, per_device_eval_batch_size=32, learning_rate=3e-5, # BERT 微调常用区间 2e-5 ~ 5e-5 num_train_epochs=4, warmup_ratio=0.1, weight_decay=0.01, logging_steps=50, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", ) trainer = Trainer( model=model, args=args, train_dataset=train_dataset, eval_dataset=eval_dataset, data_collator=collator, compute_metrics=compute_metrics, ) trainer.train()几个参数的实际含义:learning_rate=3e-5是微调预训练模型的安全区,调到 1e-4 常常训崩,调到 1e-6 又基本学不动;warmup_ratio=0.1让前 10% 的 step 学习率线性爬升,避免一开始就把预训练权重冲坏;load_best_model_at_end=True配合metric_for_best_model才能在训练结束时自动回到 F1 最高的那个 checkpoint,不然拿到的是最后一轮,而最后一轮经常已经过拟合了。
数据量小的时候(几百句),num_train_epochs=4已经足够,再多就是记答案。判断过拟合看两个数:训练 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 推理与 decode
import torch def extract_entities(text, model, tokenizer, id2label, device="cpu"): model.eval() enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=256) 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_ok=True) 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_ascii=False, indent=2)加载的时候先读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_mapping:
fast_tok = BertTokenizerFast.from_pretrained("./pretrained/bert-base-chinese") enc = fast_tok("张三在北京工作", return_offsets_mapping=True) # 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 对不对得上,再决定要不要写自定义逻辑。希望这套拆法能帮你少走两晚弯路。
本文还有配套的精品资源,点击获取