简介:面向中文自然语言处理与深度学习入门者,这套代码项目演示了如何用BERT预训练模型结合BiLSTM和CRF完成命名实体识别任务,覆盖数据清洗、分词、标签转换、模型训练、评估及在线预测全链路。压缩包共55个文件,以Python脚本为主体(34个py文件),包含核心模型实现、训练辅助模块、服务端与客户端测试脚本,同时提供11张运行效果图、4个Markdown说明文档和若干配置文件,整体仅482KB,结构紧凑。代码在bert_lstm_ner.py中完成BERT与BiLSTM-CRF的融合,并提供data_process.py、train_helper.py等工具,方便读者理解序列标注的数据流与模型封装;配套的server和client模块则实现了简单的HTTP预测服务,便于快速体验。目前已有85人学习浏览,适合具备一定Python和深度学习基础、想动手复现或改造NER系统的开发者,可直接参考其工程组织与接口设计。
1. 从字序列到"谁是谁":BERT-BiLSTM-CRF 这套框架到底在解决什么
做信息抽取的人早晚会遇到同一道坎:模型读懂了句子,却不知道哪几个字该圈成一个实体。BERT-BiLSTM-CRF 就是命名实体识别(NER)落地时最稳的经典组合——BERT 给每个字配上有上下文的向量,BiLSTM 沿时间步再扫一遍双向信息,CRF 用转移矩阵把"B-PER 后面必须跟 I-PER"这类序列规则写进损失。拿到这类源码时,模型结构反而是最不用动的部分,真正决定能不能跑通的是标签对齐、损失计算和实体解码。下文按"为什么这么组合、最小工程怎么搭、参数与推理的坑、上线前查什么"四层推进,给已经跑过 BERT 分类、准备转向序列标注的工程师指一条能落地的路线。
2. 为什么是 BERT + BiLSTM + CRF:命名实体识别三层各自干什么
先给结论:这套组合不是无脑把三个模型叠起来,而是三个组件分别回答了 NER 的三个问题——每个字拿什么向量表示、当前位置该贴什么标签、标签之间怎么衔接才算合法。三个问题拆开都能单独回答,但组合在一起,才是几千条标注数据下效果最稳的默认方案。网上对"BERT 之后还要不要 BiLSTM"吵了很多年,实践里我的判断是:数据量小、实体边界依赖局部形态时,加一层 BiLSTM 通常能换来 F1 的稳定上涨,代价只是多出几千个可学习参数。
2.1 BERT 层:让"字"第一次带上上下文
词向量时代最大的尴尬是一词一义。"小米"在"小米粥"和"小米公司"里共用同一个向量,实体识别模型只能靠字面硬猜。BERT 模型把每个 token 放进整句话里过十几层 Transformer,同一个字在不同上下文里拿到不同向量,这是 NER 效果质变的根源。中文场景下按字切分,一个字就是最小粒度,省去了分词错误向下传播的问题。
from transformers import AutoTokenizer, AutoModel tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModel.from_pretrained("bert-base-chinese") inputs = tokenizer("小米公司在北京发布新品", return_tensors="pt") outputs = model(**inputs) # outputs.last_hidden_state 形状: [batch_size, seq_len, hidden_size]说明:last_hidden_state里每个位置对应一个 token 的上下文向量,BERT-BiLSTM-CRF 源码中 BiLSTM 的输入就是它。参数上,bert-base-chinese 的 hidden_size 是 768,所以后续 BiLSTM 的 input_size 直接取bert.config.hidden_size,不要写死 768,否则换 multilingual 或领域预训练模型时又要连带改。李沐讲 BERT 的专题里反复强调过一个观点:预训练模型的收益大多来自表示替换而非结构加深,这在 NER 上体现得最明显——换成通用中文 BERT 后,不改任何下游结构,F1 往往就能比随机初始化的 BiLSTM-CRF 高七八个点。
2.2 BiLSTM 层:在时间步上再做一次双向归纳
BERT 已经带上了上下文,BiLSTM 再扫一遍,扫的是"当前的序列特征"。实体类型经常依赖局部形态:人名偏爱姓加名结构,地名偏爱"省、市、县"结尾,组织名里高频出现"集团、银行、研究院"。这类 n-gram 形态不是靠全局注意力记住的,而是靠局部滑窗归纳出来的。BiLSTM 在这里相当于一个参数可控的序列特征抽取器,把 BERT 输出压缩成更贴近标签任务的表示。
self.bilstm = nn.LSTM( input_size=bert.config.hidden_size, # 768 hidden_size=bilstm_hidden, # 256 num_layers=1, batch_first=True, bidirectional=True, ) self.classifier = nn.Linear(bilstm_hidden * 2, num_labels)说明:bidirectional=True时每个位置拼接正反向两个 hidden state,所以 Linear 的输入维度要写成bilstm_hidden * 2。常见源码把 num_layers 写成 2 或 3,实际收益很小,反而让梯度在长序列上更难传;我一般固定在 1 层,把省下的显存给 batch size。dropout 放在 LSTM 输出与分类层之间,0.1 到 0.3 之间调,不要对 BERT 输出做太大扰动。
2.3 CRF 层:把标签转移写进损失,而不是写进规则
独立 softmax 解码对每个位置单独取最大概率,完全不看相邻标签。于是推理结果里会出现 I-PER 开头、O 之后直接接 I-ORG、同一实体中间标签跳变这类非法序列。CRF 的做法是加一个可学习的转移矩阵,形状是num_labels × num_labels,训练时把整条路径的分数当作目标,推理时用维特比找全局最优路径。非法转移不是靠人工规则禁掉的,而是转移矩阵里对应位置的分数被训练数据压下去。
| 对比项 | 位置独立 Softmax | 线性链 CRF |
|---|---|---|
| 标签间依赖 | 不考虑 | 显式建模相邻标签转移 |
| 解码方式 | 每位置取 argmax | 维特比全局最优 |
| 训练目标 | 逐位置交叉熵 | 整条序列负对数似然 |
| 非法标签序列 | 可能出现在结果里 | 被转移分数压制 |
| 额外代价 | 无 | 一个转移矩阵,复杂度 O(n·L²) |
n 是序列长度,L 是标签数量。标签数量在 20 以内时,CRF 的复杂度完全不是瓶颈;如果任务有几十上百种细粒度标签,才需要考虑全局指针之类的替代方案。
2.4 标签体系先定下来:BIO 与 BIOES
写代码之前先定标签集。BIO 用 B 表示实体开头、I 表示实体内部、O 表示非实体;BIOES 在此基础上多出 E(结尾)和 S(单字实体)。源码里默认用 BIOES 的占多数,因为实体边界信息更显式,对只有一个字的实体(如"京")也有独立的 S 标记,CRF 学起来更轻松。
LABELS = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"] label2id = {l: i for i, l in enumerate(LABELS)} id2label = {i: l for l, i in label2id.items()}label2id 一旦定下来就不要中途改动,训练、验证、推理三处的 id2label 必须来自同一个映射文件,这是源码里最容易被改坏的全局状态。给数据打标签时,O 的 id 必须为 0,后面写 padding 逻辑时会依赖这个约定。
3. 命名实体识别最小工程:从 CONLL 数据到训练循环
拿到源码的第一步不是读论文,而是把数据喂进 DataLoader。序列标注的标准输入是"字 + 标签"的等长序列,工程上绕不开数据加载、模型拼装、loss 计算三个环节。这一章给出一套能直接替换进大多数源码工程的最小实现。
3.1 数据格式与加载:CONLL 风格文件怎么读
NER 数据最常见的落盘格式是 CONLL 风格:每行一个字加一个标签,空行分隔句子。中文开源数据(如 CLUENER)一般长这样:
小 B-PER 明 I-PER 现 O 在 O 北 B-LOC 京 I-LOC 上 O 班 Odef load_conll(path): samples = [] tokens, labels = [], [] with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: if tokens: samples.append((tokens, labels)) tokens, labels = [], [] continue parts = line.split() tokens.append(parts[0]) labels.append(parts[-1]) return samples说明:空行表示一句话结束,内层 if 负责把上一句收尾。parts[0]取字、parts[-1]取标签,中间列(词性、位置等)直接跳过。改别人源码时最容易踩的坑是列顺序:有的文件把标签放在第二列,有的放在最后一列,读进来之前先用head命令确认格式,而不是直接改读取逻辑。
3.2 模型拼装:BERT 主干 + BiLSTM + CRF 的 PyTorch 写法
完整模型代码量不大,核心就四段:加载 BERT、双向 LSTM、分类头、CRF。CRF 层大部分源码直接复用 torchcrf,自己实现反而容易在前向算法上出错。
import torch import torch.nn as nn from torchcrf import CRF from transformers import AutoModel class BertBiLstmCrf(nn.Module): def __init__(self, bert_dir, num_labels, bilstm_hidden=256, dropout=0.1): super().__init__() self.bert = AutoModel.from_pretrained(bert_dir) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=bilstm_hidden, num_layers=1, batch_first=True, bidirectional=True, ) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(bilstm_hidden * 2, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, label_ids=None): last_hidden = self.bert( input_ids=input_ids, attention_mask=attention_mask ).last_hidden_state seq_out, _ = self.bilstm(last_hidden) logits = self.classifier(self.dropout(seq_out)) mask = attention_mask.bool() if label_ids is not None: nll = -self.crf(logits, label_ids, mask=mask, reduction="mean") return nll return self.crf.decode(logits, mask=mask)说明:forward 里用一个 mask 同时服务两个目的——attention_mask 负责 BERT 的 padding 遮蔽,转成 bool 后作为 CRF 的 mask,告诉 CRF 哪些位置是真实 token。label_ids 不为空时走训练分支,返回负对数似然;为空时走推理分支,返回每句话的标签序列。训练和推理共用一个 forward,不容易出现两边行为不一致。
3.2.1 CRF 的损失为什么不是交叉熵
初学者最容易问:把 logits 拿去算交叉熵不就行了吗,为什么要多套一层 CRF?区别在目标。交叉熵逐位置优化,模型只需要把每个位置猜对;CRF 优化的是整条序列的路径分数,等于"每个位置猜对"加上"相邻标签组合合法"。torchcrf 内部维护一个num_labels × num_labels的转移矩阵,训练时计算负对数似然,推理时跑维特比。转移矩阵的初始值不影响最终效果,训练中会被数据校正;真正影响结果的是 mask 是否准确,mask 漏掉 padding 位置,CRF 就会在 PAD 上学习无意义的转移。
3.3 训练循环与分层学习率
训练时最值得抄的配置是分层学习率。BERT 是预训练权重,学习率大了直接破坏学到的表示;BiLSTM 和分类头是随机初始化,可以给更大的步长;CRF 只有转移矩阵几十几百个参数,可以再放宽。
| 参数分组 | 学习率 | 理由 |
|---|---|---|
| bert | 2e-5 | 预训练权重,只做微调 |
| bilstm + classifier | 5e-4 | 随机初始化,收敛快 |
| crf | 1e-3 | 参数量极小,需要相对大的更新步长 |
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer = AdamW([ {"params": model.bert.parameters(), "lr": 2e-5}, {"params": model.bilstm.parameters(), "lr": 5e-4}, {"params": model.classifier.parameters(), "lr": 5e-4}, {"params": model.crf.parameters(), "lr": 1e-3}, ], weight_decay=0.01) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=total_steps * 0.1, num_training_steps=total_steps ) for step, batch in enumerate(dataloader): loss = model(**batch) # forward 内部返回 NLL loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() optimizer.zero_grad()说明:loss.backward之后必须做梯度裁剪。Transformer 叠加 LSTM 之后梯度范数很容易冲到几十甚至上百,不裁剪的话偶尔一个样本就会让 CRF 转移矩阵跳飞,表现为训练 loss 突然 NaN 或 F1 骤降。max_norm 取 5.0 是我常用的起点,CRF 层参数量小,对裁剪阈值不敏感。
4. 训练参数与推理解码:源码里决定 F1 的细节
模型能跑起来只是第一步。同样的源码,有人 F1 到 92,有人停在 85,差距几乎全在"标签对齐、mask、解码"这三个细节上。这一章把参数表和实现细节并列着讲。
4.1 一套可以直接抄的参数表
| 参数 | 推荐起点 | 说明 |
|---|---|---|
| max_len | 128 | 中文一句平均 30~50 字,128 足够覆盖 95% 样本 |
| batch_size | 16 / 32 | 显存不够时先降 batch,而不是降 max_len |
| epochs | 5 ~ 10 | 配合验证集早停,看 F1 不再涨就停 |
| warmup 比例 | 0.1 | 前 10% 步数线性升到目标学习率 |
| 梯度裁剪 | 5.0 | 防止 CRF 层梯度爆炸 |
| 验证频率 | 每 500 步 | 不要等到 epoch 结束才看验证集 |
max_len 不是越大越好。序列标注的注意力复杂度随长度平方增长,128 升到 256,显存和训练时间几乎翻倍,而实体级 F1 的提升通常不到一个点。如果文本确实长,优先考虑切窗而不是加 max_len。
4.2 标签对齐:词表切分带来的隐形偏移
这是源码里最隐蔽的错误来源。BERT 的 tokenizer 会做三件事:在句首加 [CLS]、句尾加 [SEP]、补 padding 到 max_len。英文还会把词切成子词。任何一步没有同步处理标签序列,训练时就出现"字对不上标签",模型却不会报错,只是 F1 上不去。
def encode_with_alignment(text_tokens, label_ids, tokenizer, max_len): input_ids = [tokenizer.cls_token_id] aligned_label_ids = [0] # CLS 位占一个标签位 for tok, lbl in zip(text_tokens, label_ids): input_ids.append(tokenizer.convert_tokens_to_ids(tok)) aligned_label_ids.append(lbl) input_ids.append(tokenizer.sep_token_id) aligned_label_ids.append(0) # SEP 位同理 attention_mask = [1] * len(input_ids) pad_len = max_len - len(input_ids) if pad_len < 0: input_ids = input_ids[:max_len] aligned_label_ids = aligned_label_ids[:max_len] attention_mask = attention_mask[:max_len] else: input_ids += [tokenizer.pad_token_id] * pad_len aligned_label_ids += [0] * pad_len attention_mask += [0] * pad_len return input_ids, attention_mask, aligned_label_ids说明:CLS 和 SEP 位置各占一个标签位,补 0 也就是 O 的 id;padding 位置标签也补 0,但 attention_mask 为 0,CRF 不会在这些位置上计算转移。这里依赖 2.4 的约定:O 的 id 是 0。如果源码里用nn.CrossEntropy实现简化版 NER,通常改用 -100 填充 padding 位置的标签,配合ignore_index=-100,效果等价,两种写法不要混用。
如果做英文 NER,tokenizer 会把 running 切成 run 加 ##ning,一个词对应多个标签。这时要么用 tokenizer 返回的 offset_mapping 反查每个子词对应原词的标签,要么在加载数据时直接拒绝子词切分跨标签的样本。大多数中文源码不处理这个分支,一旦换成英文数据就静默出错。
4.3 推理解码:维特比之后还要做实体还原
源码里的 decode 分支一般调用crf.decode返回每个 token 的标签 id,但 NER 的最终产物是实体列表(类型加起止位置),中间还差一步合并。
def entities_from_tags(tag_ids, id2label): entities = [] ent_type, ent_start = None, None for i, tag_id in enumerate(tag_ids): label = id2label[tag_id] if label.startswith("B-"): if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type, ent_start = label[2:], i elif label.startswith("I-"): if ent_type != label[2:]: if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type = None else: # O if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type = None if ent_type is not None: entities.append((ent_type, ent_start, len(tag_ids))) return entities说明:合并逻辑只有三条规则——遇到 B 开启新实体,遇到同类型 I 继续,遇到 O 或不同类型的 I 关闭当前实体。BIOES 要多处理 E 和 S 两个分支:E 负责收尾,S 表示单字实体。源码里如果 decode 走的是logits.argmax而不是crf.decode,相当于绕过了维特比,非法标签序列会回流到实体合并函数里,表现为"实体类型乱跳"。
提示:上线时把 decode 分支单独抽成接口并做一次 gold 数据上的回代测试。很多源码项目训练部分很完整,decode 部分却写了两个版本(一个用于 eval、一个用于线上),两边标签映射不一致的事故我见过不止一次。
5. 上线前的三个检查:实体级评估、切窗策略与源码边界
模型训完不等于能上线。最后补三个在真实项目中反复踩到的检查点,每个都能单独落地。
5.1 不看 token 准确率,只看实体级 F1
NER 数据里 O 标签通常占 80% 以上,token 级准确率即使模型什么都没学会也能到 85%。唯一能反映业务价值的指标是实体级 F1:起始位置、结束位置、类型三者完全一致才算一个正确实体。
def entity_f1(pred_entities, gold_entities): p = {(s, e, t) for s, e, t in pred_entities} g = {(s, e, t) for s, e, t in gold_entities} tp = len(p & g) precision = tp / len(p) if p else 0.0 recall = tp / len(g) if g else 0.0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 return precision, recall, f1统计时把实体的三元组装成 set 再求交集,起止和类型任何一个对不上都不算命中。验证集上同时打印整体实体级指标和每类实体的单独 F1——整体 90 分但 ORG 只有 70 分的模型,上线后往往就在组织名上翻车。O 标签占比过高时不要急着调 CRF 的转移矩阵,先看稀有类实体的召回率,多半是标注样本太少而不是模型结构问题。
5.2 长文本切窗:实体被拦腰切断的兜底
max_len 设成 128 之后,超过长度的文本必然要切。粗暴的切法按固定长度硬切,实体就可能在窗口边界被切成两半,两个窗口各识别出一半。我一般用 75% 重叠的滑窗,切分点优先落在标点或换行处,再把重复识别的实体按置信度更高的那个去重。如果业务允许,按句子切是最省心的方案——句子是语义最小的完整单元,跨句实体(人名跨句完型)属于另一个层面的问题,不要靠切窗解决。
5.3 和 LLM 大模型方案的分工
做意图识别时大家纠结 textcnn、BERT 和 LLM 大模型的取舍,这个问题搬到命名实体识别上要换个坐标系。意图识别是句子级任务,LLM 的思维链优势能直接发挥;NER 是 token 级任务,要求输出严格对齐输入、格式稳定、延迟可控。在批量离线抽取、schema 频繁变化的场景,让 LLM 大模型零样本抽取确实方便;但在高 QPS 在线接口、schema 固定、需要对每个字符负责的场景,BERT-BiLSTM-CRF 这套框架依然是性价比最高的选择,单条推理在 CPU 上几十毫秒,GPU 上可以压到十毫秒以内。两条路线不是替代关系——先用小模型兜底高流量,再用 LLM 处理长尾难例,是当前团队最常见的分工。源码边界上记住一条:模型结构尽量不动,只动 config 和数据处理层,任何对 BERT-BiLSTM-CRF 结构本身的"顺手优化",都要先跑一遍实体级 F1 回归,确认指标没有倒挂再合入。
本文还有配套的精品资源,点击获取