基于spaCy的中文命名实体识别实战:从数据准备到模型调优

发布时间:2026/10/19 2:58:23
基于spaCy的中文命名实体识别实战:从数据准备到模型调优
最近在做一个信息抽取的小项目数据是从一堆新闻稿和合同文本里扒出来的目标很直接把人名、地名、机构名标出来。一开始我习惯性地想用英文 NLP 那套流程直接套结果发现中文命名实体识别NER在 spaCy 里远不是“换个语言代码”那么简单。分词边界、标注一致性、模型训练时的类别混淆每一步都有文章。这篇文章就围绕“基于 spaCy 的中文命名实体识别”展开聊聊我实际落地时怎么准备数据、怎么训练自定义模型、怎么评估和调优最后附上一份踩坑速查表。适合正在做中文信息抽取的开发者也适合准备入坑 NLP 的学生内容偏实操能直接照着改。1. 动手前先想清楚中文NER到底难在哪1.1 英文那套经验照搬到中文会翻车spaCy 的核心处理单元是 token整个流水线从分词、词性标注到句法分析、NER全都建立在 token 流之上。英文里 token 基本靠空格和标点切分哪怕用最朴素的规则也能得到八九不离十的分词结果所以 NER 模型可以把大部分精力放在“这个 token 序列是不是实体”上。中文没有空格。分词本身就是一道关卡而分词结果稍有不慎实体边界就跟着错。最典型的一类句子是“南京市长江大桥”不同分词方式会得到“南京市/长江大桥”或“南京/市长/江大桥”对应的实体类型完全不一样。这类歧义在真实文本里非常常见尤其是公司名和地名混合出现的时候。所以做中文 NER第一件事就是接受一个现实实体识别错误里有相当大比例其实是分词错误或者 token 切分不理想导致的模型层面未必能完全兜住。与其后面调参不如在分词和数据准备阶段就把边界问题想清楚。1.2 spaCy 处理中文的三种现实路径spaCy 本身以英文模型最为成熟中文支持属于“能用但不是开箱即用”。实际项目里一般有三条路路径优点缺点直接用社区中文预训练流水线上手快包含分词、NER等组件模型版本和 spaCy 版本耦合领域实体覆盖差借助多语言 NLP 工具的适配层能拿到质量不错的中文基础模型依赖较重扩展自定义实体需要额外学习成本用空白中文流水线自己训练 NER完全可控针对自有数据优化需要自行准备标注数据训练周期较长我这次选择的是第三条路自己训练。因为项目里的文本属于特定垂直领域人名地名还好机构名里很多是内部叫法和简称通用预训练模型基本认不出来。自己训练虽然前期工作量多一些但后续迭代空间大不会受限于预训练模型的词表。1.3 先定实体类别再谈模型很多人一上来就定义八类十类实体人名、地名、机构名、时间、日期、金额、产品名、事件名全都要。这个想法可以理解但实践里非常容易翻车。实体类别越多每类的训练样本就越稀疏模型要学的判别边界也越复杂最后往往是所有类别都做不好。比较好的做法是先挑 2 到 3 个高频且边界相对清晰的类别。比如新闻类文本先做人名PER、地名LOC、机构名ORG合同类文本可能优先做公司名、金额、日期。我这次先做了三类等模型基线跑通、标注数据攒够之后再考虑是否增加产品名或事件名。注意实体类别定义要写清楚判定规则。比如“北京大学”到底算机构名还是地名“上海交通大学附属医院”里的“上海”要不要单独标这些规则如果不提前统一标注人员之间会出现严重的不一致模型学到的全是噪声。2. 训练数据准备一句话标注错后面全是坑2.1 标注体系选 BIO 还是 BILOU讲训练之前必须先把数据格式说清楚。主流的序列标注体系有 BIO、BIOES、BILOU 等核心思想都是让模型知道实体的起始位置、内部位置和结束位置。体系标签示例特点BIOB-PER, I-PER, O最常用实现简单BIOESB-PER, I-PER, E-PER, S-PER, O额外区分结束和单字实体序列模型效果好BILOUB-PER, I-PER, L-PER, U-PER, O与 BIOES 本质相同是 spaCy 内部偏好的表示方式实际用 spaCy 训练时不需要自己手工把每个 token 的标签写出来。spaCy 接受的是实体 span 列表也就是每个实体在原文中的起始字符位置、结束字符位置和类别。你只需要把标注结果转成 spaCy 的 DocBin 格式剩下的编码工作交给框架处理。我习惯在标注时就按“可复读”的格式落地比如用 JSON 保存成下面这样[ { text: 张伟任职于星河科技有限公司负责华东区市场。, entities: [ [张伟, PER], [星河科技有限公司, ORG], [华东区, LOC] ] } ]这里的起止位置虽然是字符串形式但转换成 spaCy 训练数据时要用字符偏移量后面会讲到。2.2 从标注文本到 DocBinspaCy 训练 NER 的推荐格式是.spacy文件内部是 DocBin 序列化结果。转换过程不复杂核心代码如下import spacy from spacy.tokens import DocBin nlp spacy.blank(zh) docbin DocBin() train_data [ (张伟任职于星河科技有限公司负责华东区市场。, [(0, 2, PER), (5, 12, ORG), (16, 19, LOC)]), ] for text, annotations in train_data: doc nlp.make_doc(text) entities [] for start, end, label in annotations: span doc.char_span(start, end, labellabel) if span is not None: entities.append(span) doc.ents entities docbin.add(doc) docbin.to_disk(train.spacy)这段代码里最容易踩坑的是doc.char_span。它要求传入的字符偏移必须能落在合法的 token 边界上。如果 tokenizer 在实体中间切了一刀比如“星河科技”没有被切成一个 token那么 char_span 很可能返回 None这个标注就直接被丢弃了。后面我会专门讲这个问题怎么排查。另一个细节是nlp.make_doc只做分词不做完整流水线处理。用spacy.blank(zh)创建空白流水线时要确保中文分词器可用否则这一行就会报错。这也是很多人从英文项目切到中文项目遇到的第一个坑。2.3 数据清洗和一致性检查不可跳过标注数据不是越多越好而是越一致越好。我把数据分析的经验总结成三条硬性规则。第一条统一嵌套实体策略。真实文本里实体经常嵌套比如“上海市第一人民医院”里“上海市”是地名“医院”是机构名。如果两层都想标spaCy 默认只支持不重叠的实体集合。我的经验是优先保留最长实体在转换时用filter_spans做去重它会在重叠 span 里综合长度和顺序保留一个。第二条注意标点、数字和全半角符号。同一句话里“2023年”和“年”会让模型学到完全无关的干扰信息。训练之前最好统一做规范化把全角数字转半角把英文标点转中文标点减少无意义的输入多样性。第三条定期抽检标注一致性。哪怕你就是一个人标注隔两周回看之前的结果也可能会发现前后标准不一致。我一般会把已经标好的数据按文本相似度聚类一下挑出容易混淆的句子重点检查。实体标注看起来是个体力活但真正花时间的其实是这种一致性校验。3. 从空白流水线开始训练 NER 模型3.1 初始化流水线并注册标签用spacy.blank(zh)创建空白流水线后需要主动加入 NER 组件。注意顺序很重要。如果后面还要加其他组件NER 一般放在分词之后、依赖解析之前但单独训 NER 时不需要考虑太多。import spacy nlp spacy.blank(zh) ner nlp.add_pipe(ner) ner.add_label(PER) ner.add_label(LOC) ner.add_label(ORG)标签要在转换训练数据之前注册好或者也可以先跑一遍训练数据自动收集标签但手动注册更保险也方便检查类别名称是否一致。我遇到过类别名不统一导致训练集标签和 dev 集标签错位的情况手动注册能提前暴露问题。3.2 完整训练数据转换脚本下面是一个能直接跑通的示例脚本包含了读取 JSON 标注、创建 DocBin、过滤重叠实体、划分训练集和验证集的完整流程。import json import random import spacy from spacy.tokens import DocBin from spacy.util import filter_spans nlp spacy.blank(zh) def load_annotations(json_path): with open(json_path, r, encodingutf-8) as f: return json.load(f) def convert_to_docbin(data, nlp, output_path): docbin DocBin() for item in data: text item[text] doc nlp.make_doc(text) entities [] for start, end, label in item[entities]: span doc.char_span(start, end, labellabel) if span is not None: entities.append(span) doc.ents filter_spans(entities) docbin.add(doc) docbin.to_disk(output_path) all_data load_annotations(annotations.json) random.shuffle(all_data) split int(len(all_data) * 0.8) convert_to_docbin(all_data[:split], nlp, train.spacy) convert_to_docbin(all_data[split:], nlp, dev.spacy)这段脚本我在多个小项目里改过很多次。有一个细节要特别留意random.shuffle之前最好按文本去重否则同一句话在训练集和验证集里同时出现评估结果会虚高上线后立刻现原形。3.3 配置文件与训练命令spaCy 3.x 用配置文件驱动训练好处是参数可复现。先执行初始化命令生成基础配置再自己调整关键参数。python -m spacy init config base_config.cfg --lang zh --pipeline ner --optimize efficiency python -m spacy init fill-config base_config.cfg config.cfg然后修改config.cfg里的路径参数指向你生成的.spacy文件[paths] train train.spacy dev dev.spacy训练命令长这样python -m spacy train config.cfg --output ./output --gpu-id 0没有 GPU 也能跑就是慢一些。小规模数据在 CPU 上也能接受但如果标注数据超过一万句建议还是上 GPU。3.4 训练过程中的关键参数配置项很多但真正值得关注的其实就那么几个参数建议值说明training.max_epochs50 左右起步数据少时容易过拟合数据多时可以适当增加training.patience1500 步连续多少步没有改善就提前停止training.dropout0.2 到 0.5防止过拟合数据少时建议大一点training.batch_size16 到 64显存允许的话尽量大一些训练更稳定components.ner.model.arch默认即可小数据集用默认架构别随意魔改很多人误以为 epoch 越多越好。实际上 NER 任务在小数据集上跑到二十几个 epoch 后F1 往往就不再上升继续跑反而会过拟合。我的习惯是把max_epochs设大一点但patience设小一点让训练提前停在不那么差的位置再根据 dev 集的曲线微调。训练结束后output目录下会出现多个模型文件夹spaCy 会用 dev 集分数挑选最佳模型文件名里带best的那个就是我们要的。4. 评估指标与调优别只盯着F14.1 怎么算一次“正确”的预测NER 的评估指标是精确率、召回率和 F1但这里的“正确”标准比分类任务严格得多。实体预测必须类别对、边界也对差一个字符都算错。举个例子原文是“星河科技有限公司”模型预测的是“星河科技”词义上没啥大毛病但实体边界少了“有限公司”四个字在评估时就是一个完完全全的错误预测同时会拉低精确率和召回率。反过来如果真实标注只标“星河科技”模型却预测了全称同样算错。我后来把评估结果单独拎出来看过发现边界错误的占比经常超过类别混淆。这说明问题不只是模型学得不好更多是数据标注的一致性在拖后腿。简单的评估脚本如下import spacy from spacy.training import Example from spacy.scorer import Scorer nlp spacy.load(./output/model-best) scorer Scorer() examples [] # dev 数据转换过程省略 # for pred_doc, gold_doc in dev_examples: # examples.append(Example(pred_doc, gold_doc)) scores scorer.score(examples) print(scores[ents_p], scores[ents_r], scores[ents_f])没有这个脚本之前我都是凭感觉判断模型好坏。有了一份可重复的评估脚本之后每次调参才有客观依据。4.2 快速提点的五个招模型跑通之后调优方向比想象中要朴素得多。我试过比较有效的手段按优先级排列如下第一招增加难例。把模型预测错误的句子收集起来用 entity ruler 或者人工复核后重新加入训练集。这种方法比盲目增加随机数据效率高很多。第二招规则兜底。用 spaCy 的 EntityRuler 组件把高频且固定的实体放进白名单比如常见地名简称、公司全称。模型专注发现新实体规则保证高频实体不出错。第三招做数据增强。对实体少、句子结构简单的样本可以替换实体名生成新句子。比如“张伟在星河科技工作”可以变成“李强在蓝月信息公司工作”。注意别过度增强否则句子模式过于单一。第四招平衡类别。如果机构名样本是地名的三倍模型会偏向把所有疑似实体都判断成机构名。最简单的方法是下采样高频类别或者给低频类别复制增强。第五招清理验证集。每次评估前检查 dev 集是否混入了和训练集高度重复的句子否则调参方向会被带偏。4.3 类别混淆的实际案例中文 NER 里最容易混淆的类别是地名和机构名。很多机构名本身嵌着地名比如“上海交通大学”里的“上海”以及“北京研究中心”里的“北京”。如果上下文里出现“位于”模型很容易把整个短语标成地名。碰到这种情况我通常会给 EntityRuler 加几条常见机构名后缀规则比如“大学”“医院”“公司”“研究院”结尾的词组优先作为机构名候选再让模型去判断边界。规则不需要覆盖所有情况只要把高频确定性强的先接住模型的学习压力就会小很多。另一个常见混淆是人名与地名简称。比如“小王去了深圳”没问题但“王庄位于河北”里的“王庄”是地名模型需要上下文才能判断。这种只能靠更多标注样本和上下文特征没有特别好的捷径。5. 实战中踩过的坑一份排查速查表5.1 训练时 loss 降得很快但 F1 纹丝不动这种情况我遇到过两次原因不一样。第一次是训练集和 dev 集有重复句子模型在训练时已经把 dev 集的内容背了下来但 spaCy 的 scorer 算的是精确边界匹配背下来也只能得训练集里见过的那部分分。第二次是数据标注质量太差大量实体边界标错了位置模型学不到真实规律。先做数据去重再做标注抽检。如果这两步都做完了 F1 还是上不去把实体数量统计打出来看看很可能某个类别只有几十条样本不够模型学。5.2 char_span 总是返回 Nonechar_span返回 None 通常是因为字符偏移没有落在 token 边界上。中文分词器有时候会把“星河科技”切成“星河”和“科技”而标注想标的是“星河科技”整体于是 char_span 找不到完整对齐的 token 切片。最简单的解决办法是把实体边界扩大到包含它所在的两个 token。比如原本想标“星河科技”分词后是“星河 / 科技”那就把起止位置从 token 开头扩到 token 结尾。如果这种情况频繁出现说明当前分词规则不适合你的领域可以考虑在分词器中加入领域词表或者干脆改成更保守的字符级切分。5.3 模型把所有连续词都识别成机构名这是典型的类别不平衡加数据模式单一导致的。如果训练语料里很多机构名都是三到五个字的连续名词模型会学到“连续几个名词组合起来大概就是机构名”这种偷懒的规则。解决办法是加负样本也就是大量没有机构名的句子并且保证这些句子里也有连续名词短语。比如“市场发展速度很快”这样的句子要频繁出现让模型知道连续名词组合并不一定是实体。注意实体识别不是“能从文本里找出几个词”就行更重要的是“知道哪里不是实体”。负样本和正样本同样重要。5.4 训练好的模型换个环境就加载失败spaCy 对版本很敏感。模型训练时的 spaCy 版本和推理环境的版本不一致轻则警告重则直接无法加载。我吃过一次亏本地训练用的 spaCy 是 3.7线上环境还是 3.5模型加载时直接报配置解析错误。建议项目一开始就在requirements.txt里锁定 spaCy 版本训练和推理共用同一套虚拟环境。如果必须升级 spaCy最稳妥的做法是重新生成训练数据并重新训练别指望旧模型能平滑迁移。5.5 训练数据太少怎么办如果每个类别只有一两百条标注模型很难达到实用水平。这种情况下我不建议硬着头皮加大模型复杂度而是先把规则系统搭起来。用 EntityRuler 维护一份高频实体名单基于词典做实体识别准确率可以做到很高只是召回率偏低。后续再用标注数据和训练模型去补齐规则覆盖不到的部分。这种“规则兜底 模型扩展”的模式在小成本和垂直领域场景里非常管用。等数据量上来之后再把规则逐步拆掉让模型承担主要识别任务。我实际操作下来的体会是中文 NER 在 spaCy 里的技术门槛其实不算高真正难的是对数据质量的把控和实体定义的统一。模型只是一头很聪明的驴拉车方向还是得靠人定。数据清理做得越扎实训练出来的模型就越省心。最后再分享一个小技巧训练结束后不要急着删掉带last的模型权重。有时候best模型虽然在 dev 集上分数高但在某个特定场景下表现反而不如后期训练出来的last模型。保留多个 checkpoint在线下测试集上多跑几轮再决定用哪一个会比只信一个指标稳妥得多。