11.4.5 NER 实战

NER 项目不要只盯 token accuracy。先看标签体系、标注样例、实体恢复、实体级 Precision/Recall/F1 和错例分桶如何形成闭环,这比单纯换模型更像真实项目。
前两节已经把:
- 序列标注任务
- BiLSTM + CRF 的核心思想
讲清楚了。 这一节我们把它放回项目里,做一个更像真实业务的练习:
从简历文本里抽取姓名、学校和技能。
这类任务非常适合练 NER,因为它同时包含:
- 明确 span
- 明确类型
- 很多边界细节
学习目标
- 学会定义一个最小 NER 项目边界
- 学会从 token 标签恢复实体
- 学会做实体级别错误分析
- 通过可运行示例建立信息抽取项目骨架
先建立一张地图
NER 实战更适合按“标签 -> 实体 -> 评估 -> 迭代”的顺序理解:
所以这节真正想解决的是:
- NER 项目为什么不只是“标签预测”
- 为什么实体恢复和错误分析才更像真实项目
一、项目问题先要定义清楚
场景
输入:
- 一段简历或候选人简介文本
输出:
- 姓名
- 学校
- 技能
为什么这比“随便抽点实体”更适合练手?
因为它边界清楚:
- 类别数不多
- 实体类型明确
- 结果很容易做业务解释
第一个关键点不是模型,而是标签体系
例如:
张三->B-NAME清华大学->B-SCHOOL I-SCHOOL ...Python->B-SKILL
这一步一旦含糊,后面模型和评估都会一起乱。
一个更适合新人的总类比
你可以把 NER 想成:
- 在一段文字里拿荧光笔圈重点信息
难点不只是“圈出来”,而是:
- 从哪里开始圈
- 到哪里结束
- 这一段到底属于哪一类
这样理解后,为什么 NER 特别容易卡在边界问题,就会自然很多。
二、先做一个可运行标注与解码闭环
下面这个示例会做三件事:
- 准备一个小型样本
- 把 BIO 标签解码成实体
- 做简单的预测对比与错误分析
samples = [
{
"tokens": ["张三", "毕业于", "清华大学", ",", "熟悉", "Python", "和", "PyTorch"],
"gold_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL", "O", "B-SKILL"],
"pred_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL", "O", "B-SKILL"],
},
{
"tokens": ["李四", "来自", "北京大学", ",", "掌握", "Java"],
"gold_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL"],
"pred_tags": ["B-NAME", "O", "O", "O", "O", "B-SKILL"],
},
]
def decode_entities(tokens, tags):
entities = []
current_tokens = []
current_type = None
for token, tag in zip(tokens, tags):
if tag == "O":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = []
current_type = None
continue
prefix, entity_type = tag.split("-", 1)
if prefix == "B":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = [token]
current_type = entity_type
elif prefix == "I" and current_type == entity_type:
current_tokens.append(token)
else:
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = [token]
current_type = entity_type
if current_tokens:
entities.append(("".join(current_tokens), current_type))
return entities
for sample in samples:
gold_entities = decode_entities(sample["tokens"], sample["gold_tags"])
pred_entities = decode_entities(sample["tokens"], sample["pred_tags"])
print("tokens:", sample["tokens"])
print("gold :", gold_entities)
print("pred :", pred_entities)
print("miss :", [x for x in gold_entities if x not in pred_entities])
print()
预期输出:
tokens: ['张三', '毕业于', '清华大学', ',', '熟悉', 'Python', '和', 'PyTorch']
gold : [('张三', 'NAME'), ('清华大学', 'SCHOOL'), ('Python', 'SKILL'), ('PyTorch', 'SKILL')]
pred : [('张三', 'NAME'), ('清华大学', 'SCHOOL'), ('Python', 'SKILL'), ('PyTorch', 'SKILL')]
miss : []
tokens: ['李四', '来自', '北京大学', ',', '掌握', 'Java']
gold : [('李四', 'NAME'), ('北京大学', 'SCHOOL'), ('Java', 'SKILL')]
pred : [('李四', 'NAME'), ('Java', 'SKILL')]
miss : [('北京大学', 'SCHOOL')]

第二条样本漏掉了学校实体。这正说明 NER 项目不能只看 token 标签,而要看最终恢复出来的实体。
这段代码为什么是“项目最小闭环”?
因为它已经包含了:
- 数据表示
- 预测结果
- 实体恢复
- 错误分析
这比只打印一串标签更接近真实项目形态。
为什么这里按实体比较,而不是只按 token 比较?
因为业务真正关心的通常是:
- 实体有没有抽出来
- 类型对不对
而不是某一个 token 单点是否打对。
再看一个最小“实体日志”示例
sample = {
"tokens": ["李四", "来自", "北京大学", ",", "掌握", "Java"],
"gold_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL"],
"pred_tags": ["B-NAME", "O", "O", "O", "O", "B-SKILL"],
}
def decode_entities(tokens, tags):
entities = []
current_tokens = []
current_type = None
for token, tag in zip(tokens, tags):
if tag == "O":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = []
current_type = None
continue
prefix, entity_type = tag.split("-", 1)
if prefix == "B":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = [token]
current_type = entity_type
elif prefix == "I" and current_type == entity_type:
current_tokens.append(token)
if current_tokens:
entities.append(("".join(current_tokens), current_type))
return entities
gold_entities = decode_entities(sample["tokens"], sample["gold_tags"])
pred_entities = decode_entities(sample["tokens"], sample["pred_tags"])
print(
{
"text": "".join(sample["tokens"]),
"gold_entities": gold_entities,
"pred_entities": pred_entities,
}
)
预期输出:
{'text': '李四来自北京大学,掌握Java', 'gold_entities': [('李四', 'NAME'), ('北京大学', 'SCHOOL'), ('Java', 'SKILL')], 'pred_entities': [('李四', 'NAME'), ('Java', 'SKILL')]}
这个日志特别适合初学者,因为它会把一个抽象的标签任务,变成更像真实项目的输出:
- 原文本是什么
- 正确实体有哪些
- 系统到底漏了什么
三、NER 项目最该先看什么指标?
实体级 Precision / Recall / F1
这是最常见也最有意义的一组指标。
为什么 token accuracy 不够?
因为序列里往往很多都是:
O
只看 token accuracy 很容易显得“很高”, 但真正的实体抽取效果可能并不好。
一个极简实体召回例子
samples = [
{
"tokens": ["张三", "毕业于", "清华大学", ",", "熟悉", "Python", "和", "PyTorch"],
"gold_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL", "O", "B-SKILL"],
"pred_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL", "O", "B-SKILL"],
},
{
"tokens": ["李四", "来自", "北京大学", ",", "掌握", "Java"],
"gold_tags": ["B-NAME", "O", "B-SCHOOL", "O", "O", "B-SKILL"],
"pred_tags": ["B-NAME", "O", "O", "O", "O", "B-SKILL"],
},
]
def decode_entities(tokens, tags):
entities = []
current_tokens = []
current_type = None
for token, tag in zip(tokens, tags):
if tag == "O":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = []
current_type = None
continue
prefix, entity_type = tag.split("-", 1)
if prefix == "B":
if current_tokens:
entities.append(("".join(current_tokens), current_type))
current_tokens = [token]
current_type = entity_type
elif prefix == "I" and current_type == entity_type:
current_tokens.append(token)
if current_tokens:
entities.append(("".join(current_tokens), current_type))
return entities
def entity_recall(gold_entities, pred_entities):
if not gold_entities:
return 1.0
hit = sum(entity in pred_entities for entity in gold_entities)
return hit / len(gold_entities)
for sample in samples:
gold_entities = decode_entities(sample["tokens"], sample["gold_tags"])
pred_entities = decode_entities(sample["tokens"], sample["pred_tags"])
print(entity_recall(gold_entities, pred_entities))
预期输出:
1.0
0.6666666666666666
第一条样本实体全中;第二条样本只抽出了 3 个实体中的 2 个,所以实体级 recall 会下降,即使很多 O 位置仍然是对的。
第一次做 NER 项目时,最稳的默认顺序
更稳的顺序通常是:
- 先把实体类型收窄
- 先把标签标准写清楚
- 先做实体恢复和实体级评估
- 再去换更强模型
这样会比一开始就急着上 BERT 更容易把项目做稳。
四、NER 项目最常见的失败点
实体边界错
例如学校名只抽了一半。
类型错
例如把技能识别成学校。
漏实体
例如样本 2 里把 北京大学 漏掉了。
为什么这很适合做错误分析?
因为 NER 的错误通常很具体, 非常适合逐条看、逐类修。
一个新人很值得先用的错误分桶方式
第一次做错例分析时,最值得先分的通常就是:
- 边界错
- 类型错
- 漏实体
这三类已经足够帮助你判断:
- 是数据标注问题
- 是模型表示问题
- 还是后处理规则不够
五、真实项目下一步该怎么走?
扩充数据
尤其是:
- 长实体
- 稀有实体
- 容易混淆类型
从规则 / 经典模型升级到更强模型
例如:
- BiLSTM + CRF
- BERT token classification
加入后处理规则
很多业务项目里, 合理的后处理规则能明显提升实体质量。
如果把它做成项目,最值得展示什么
最值得展示的通常不是:
- 一串标签预测结果
而是:
- 原始文本
- gold 实体
- 预测实体
- 漏抽和错抽案例
- 你下一步准备优先修哪类错误
这样别人会更容易感觉到:
- 你做的是信息抽取项目
- 不只是训练了一个序列标注模型
六、最常见误区
误区一:只看 token 级指标
NER 更该看实体级效果。
误区二:一开始就想覆盖所有实体类型
更稳的做法通常是:
- 先选 2~4 类核心实体做透
误区三:标签体系一开始不定清楚
标签边界不清,数据和评估都会一起发散。
小结
这节最重要的是建立一个实战习惯:
做 NER 项目时,先把实体类型、标签体系、实体恢复和实体级错误分析做扎实,再去追求更复杂模型。
这样你留下的会是一个真正可解释、可改进的信息抽取项目,而不是只会跑训练脚本的半成品。
练习
- 给示例再加一个
ORG或TITLE实体类型,扩展样本。 - 想一想:为什么 NER 项目更适合看实体级指标,而不是 token accuracy?
- 如果系统经常把长学校名只抽一半,你会优先改数据、改模型,还是加后处理?为什么?
- 你会如何把这个简历抽取项目进一步扩成作品集展示?