第六章终于轮到动手训练模型了。前面几章我们处理了知识图谱的Schema设计、语料采集和标注规范的落地,数据已经按照CODL标注格式整理好,但这套数据目前还只是躺在文件夹里的文本,离“知识图谱”还差一个能自动从文本里抽出实体的模型。这一章要做的,就是把BILSTM+CRF这套实体识别模型真正跑通,从数据适配、模型构建、训练调参到结果评估,形成一个可以直接支撑后续三元组构建的完整链路。
这一章的内容适合谁看?如果你是正在做课程设计或毕业论文的学生,手里有标注好的数据集但模型还处于“照着网上的代码能跑、但不知道每一步为什么这么写”的状态,这一章能帮你把每个环节的取舍逻辑补齐。如果你是刚进入 NLP 领域的开发同学,想在团队内部快速搭一套实体抽取能力作为数据中台的底层模块,这一章同样可以给你一份拿来即用的训练方案和避坑清单。
1. 项目回顾与第六章的核心目标
一个完整的知识图谱项目,通常包含本体设计、实体识别、关系抽取、知识融合和图存储几条线,很多初学的人容易把全部精力压到模型训练上,忽略前后端的衔接。我们这套模拟项目X在前面章节已经完成了任务定义:面向中文医疗文本的实体识别——从病历、检查报告、检验结果这类非结构化文本里抽取出症状、疾病、药物、检查四类实体。这套任务本身就是知识抽取中最关键的一环,实体抽不出来,关系抽取和属性填充都无从谈起,整个图谱的数据层就会是空的。
1.1 从知识图谱整体架构看第六章的位置
知识图谱的自动化构建链路一般长这样:原始文本进入后进行句子边界划分和基础清洗,然后进入实体识别环节,识别出来的实体被用来做关系分类和属性对齐,最后写入图数据库供上层查询和推理使用。模型训练这个环节放在整个链路的靠前位置,它直接决定了下游能拿到什么样的原料。如果实体识别只能做到80%的F1值,那么后续的关系抽取、实体链接实际上都是在有噪音的实体集合上操作,噪音会被逐级放大。所以第六章解决的问题非常简单直接——把BILSTM+CRF模型训练到可靠的可用状态,让实体识别这个环节不要成为整个图谱构建的短板。
1.2 第六章要落地的三个核心成果
第一,把标注好的数据从纯文本形式转换成可以批量喂给TensorFlow或PyTorch训练的数据集,这一步包含分词处理、标签对齐、序列padding和词表构建,做不好后面模型再聪明也学不出东西。第二,完整搭建BILSTM+CRF模型,理解每一个网络层在干什么,尤其是CRF层为什么能纠正BILSTM单独解码时出现的标签乱序问题。第三,完成模型训练、早停策略选择和评估指标计算,用混淆矩阵和分类型P/R/F1定位模型在四类实体上的弱点,把训练结果保存成可复用的实体抽取服务。
严格来说,第六章不是“把模型跑起来就交差”,而是要让模型跑完之后的产物可以无缝对接第七章的关系抽取模块。所以本章的代码和数据结构在设计时都留好了接口,模型输出会被标准化成“实体文本 + 类型 + 起始位置 + 置信度”的四元组格式,这种格式下游模块直接使用,不需要二次加工。
2. BILSTM+CRF模型实现核心拆解
BILSTM+CRF这个组合是序列标注领域非常经典的基线方案。它在很多数据集上不是最能打的——BERT类模型基本已经超过了它——但理解这套经典的组合,对后续理解更复杂的预训练模型微调、阅读理解式抽取等方案非常有帮助。更重要的是它的训练成本低,一个普通GPU就能在小时级别跑完,对课程设计和内部工具来说性价比非常高。
2.1 为什么实体识别的默认选择是BILSTM+CRF
要理解这个选择,先拆开看两个组件各自解决什么问题。BILSTM部分负责编码上下文,它通过正向和逆向两个LSTM单元分别捕捉一个词左右两侧的语义信息。比如在“患者诉头痛伴恶心”这句话里,“头痛”能不能被识别为症状,靠的不只是它本身,还要看它左边是“诉”,右边有“伴恶心”,这些上下文组合起来才能确定“头痛”在这个句子里是症状描述而非日常词汇。LSTM在长距离依赖上比传统的n-gram窗口方法强得多,它能利用整个句子的信息来做判断,但LSTM有一个天生的短板——它每个位置的输出标签是独立softmax出来的,模型并没有学过“B后面不能直接跟I”这类标签间的强约束关系。
CRF层解决的就是这个约束问题。CRF是一种概率图模型,它会在所有可能的标签序列之间做全局最优解搜索。实践中BILSTM的输出会作为发射分数传递给CRF层,同时CRF层自己维护一个标签转移矩阵,这个转移矩阵记录的是“从当前标签转移到下一个标签”的代价。比如在BIO标注体系下,转移矩阵会让“I-疾病”出现在“O”或“B-症状”后面的概率非常低,因为模型在训练中观察到这种转移几乎不会出现。这种全局约束能力是单独使用BILSTM时完全不具备的。
所以在2003年提出的经典架构中,BILSTM负责“理解语义”,CRF负责“保证合规”,两个组件各司其职,组合起来的模型才能在实体识别上超过单用任何一种方法。使用这个组合的另一个隐性好处是可解释性相对好,CRF层的转移矩阵可以直接打印出来观察,比如某个标签转移到另一个标签的频率和代价,这对我们分析模型为什么会在某些病例上抽错实体非常有帮助。
2.2 CRF层的转移矩阵初始化与实现要点
在代码层面上,CRF层最核心的组成部分是一个shape为(标签数+2,标签数+2)的转移矩阵。为什么要加2?因为训练和推理时都需要两个特殊标签——句首的START和句尾的END。这两个标签不参与实体标签的语义输出,但它们能让模型学到“一个句子必须以O或者B开头,不能以I开头”这样的约束。
转移矩阵的初始化通常有三种做法:全零初始化、随机正态初始化、基于先验约束的初始化。全零初始化意味着模型最开始对任何标签转移都没有偏好,完全靠训练数据来自动学习,这种方案最简单但训练初期收敛会稍慢。随机正态初始化会给模型一个初始的探索方向,但如果方差设置过大,容易在早期训练阶段产生极端损失值。我在模拟项目X里使用的是小范围的随机初始化,标准差设置为0.1,同时在代码中手动干预几个特殊位置的转移值——比如强制设置从START转移到I标签的分数为一个很大的负数,这样做的好处是不需要靠数据量去硬堆这个显而易见的先验约束,模型能少走弯路。
CRF层的损失函数计算也需要重点理解。模型训练时我们计算的损失是整个正确标签序列的负对数似然,这个值的计算要穷举当前句子所有可能的标签序列并做log-sum-exp归一化。很多人第一次写CRF层会在这里卡住,因为朴素的穷举是指数级复杂度,好在句子长度在合理范围内时,使用动态规划可以高效地完成这个计算。前向计算时维护一个状态分数张量,逐步累加发射分数和转移分数,最终得到所有路径的统一归一化项。这一段是CRF层编码的难点,也是决定模型能否正确训练的关键,建议初学者先把前向算法的动态规划递推式写在草稿纸上,再对照代码来看,一次就能看明白。
2.3 从CODL标注到模型可训练样本的数据适配流程
模型能训练起来的前提是把原始的文本和标签转换成模型能读的数字张量。我们项目的标注文件是基于JSON结构的CODL格式,每条样本包含id、text和label序列。label序列中每个元素对应text中的一个字符或词,格式形如{"text": ["患", "者", "头", "痛"], "label": ["O", "O", "B-symptom", "I-symptom"]}。这种字的粒度标注方式在中文NER中非常常见,因为中文没有天然空格分词,字级别的标注能避免分词错误带来的连锁误差。
数据适配的第一步是构建字表。我们要扫描全部训练语料,统计出现的字符频次,保留高频字符构建一个词汇表,同时预留PAD、UNK两个特殊token的位置。第二步是标签映射表,把每个标签字符串映射到唯一索引,同时在映射表末尾补上START和END两个特殊标签的索引。第三步是序列对齐,因为模型通常是按batch训练的,一个batch内的句子必须等长,所以需要统一padding到相同的max_len。padding的长度选取需要用统计方法来确定,先看一下训练集中序列长度的分布情况,选择覆盖90%样本的长度作为max_len,而不是直接用最大的长度,否则会导致大量无效计算,浪费显存。
这里有一个很容易踩的坑:标签序列在做padding时补的默认值通常设置为-1或忽略索引,在PyTorch里就是CrossEntropyLoss的ignore_index参数,这样padding部分的标签才不会参与梯度计算,否则O标签会被严重高估,模型最后把所有实体都预测成O,看起来损失很低,实际F1却非常难看。
3. 训练配置、参数调优与验证评估
模型训练不是把数据丢进去然后等结果,训练过程中的每一个参数选择背后都有对应的优化目标。在模拟项目X中,我们把训练过程拆成了三个阶段:第一阶段是预热训练,用较小的学习率和较大的dropout跑前几个epoch,观察损失是否稳定下降;第二阶段是正式训练,记录验证集F1的每个epoch变化;第三阶段是收尾,使用早停和最优模型保存策略,避免过拟合。
3.1 训练参数速查表与关键讲解
以下是我在模拟项目X中经过多轮调整最后使用的参数组合,这里列出来供参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 自带动量与自适应学习率,收敛稳定 |
| 学习率 | 0.001(可配合warmup) | 过大导致损失震荡,过小收敛过慢 |
| epoch数 | 40(配合早停) | 不是越大越好,验证集F1不再上升即可停 |
| batch_size | 32 | 显存有限时可降到16,配合梯度累积 |
| embedding维度 | 128 | 字向量维度,语料小时不需要太高 |
| LSTM隐层维度 | 256 | 单层双向时256是比较均衡的取值 |
| LSTM层数 | 1~2 | 语料量不大时1层优于2层,避免过拟合 |
| dropout | 0.5 | 防止LSTM输出过拟合 |
| max_len | 128 | 从序列长度分布得出的90%分位值 |
| 梯度裁剪 | 5.0 | 防止梯度爆炸,尤其是LSTM训练初期 |
embedding维度和LSTM隐层维度这两个参数值得特别注意。很多人为了追求效果直接上512的embedding维度,但我们的训练语料只有几万条,高维embedding在数据量不足时不仅没有提升效果,反而会让模型过拟合。相比之下,用128维字向量配合256维LSTM隐层是最稳妥的组合,这也符合经典的LSTM-CRF基线配置。如果你的任务比我们的复杂——比如要识别十类以上的实体且句子特别长——再考虑增大embedding维度到200或300,同时相应增加训练数据量。
dropout这个参数也很微妙。我见过不少同学把dropout调到0.8,结果训练集F1很高、验证集F1断崖式下跌,这就是dropout过强导致的欠拟合。反过来,dropout设成0又会迅速过拟合。在语料有限的项目里,0.5是一个很安全的起始值,如果是用BERT等预训练模型做特征抽取,dropout反而可以设小一些,比如0.1到0.2,因为预训练模型的泛化能力本身已经很强了。
3.2 训练过程中的动态观察方法
训练开始后不要只看loss数字。我推荐使用类似TensorBoard的可视化工具跟踪训练进程,观察四条曲线:训练集loss、验证集loss、验证集F1、验证集精确率/召回率。在CRF类模型中有一个常见现象,就是训练集loss一开始下降很快,但验证集F1在最初几个epoch几乎不动,这是因为模型的转移矩阵和发射分数还在共同学习阶段,输出的标签序列不够稳定。遇到这种情况不要慌,继续观察,只要验证集F1在缓慢上升而不是一直在0附近徘徊就是正常的。
还有一种比较隐蔽的问题是训练loss虽然持续下降,但验证集F1在某个epoch后开始下降。这通常是过拟合的信号,此时应当触发早停逻辑。我在项目中设置的早停条件是验证集F1在连续5个epoch内没有创新高就停止训练,同时保存历史F1最高的那个checkpoint。这里有个实操技巧:checkpoint的保存不要只存模型权重,要连词表、标签映射表、模型配置参数一起存成一个压缩包,否则后续做服务化推理时还要费力对齐这些配置,特别容易出bug。
3.3 评估指标:精确率、召回率与F1到底看哪个
实体识别的评价不是简单计算每个字分类的准确率,而是以“实体片段”为单位来计算。就是说模型在一个句子中识别出了一个连续实体“高血压”,这算一个预测实体,要和真实标注的“高血压”做边界对齐和类型一致比对。一个预测实体只有在起始位置、结束位置、实体类型三个维度全部正确时才算一个正确的识别结果。在这个基础上我们可以计算精确率、召回率和F1值。
我在模拟项目X的实验中,宏观F1在0.87左右,但细分到每一类实体后发现差异很大:比如疾病类实体F1约0.89,而检查类实体只有0.82,最后导致整个系统在应用端的体验是“漏检”比“误检”严重得多。后来分析了原因,检查类实体的文本形态变化太丰富,有些是缩写(如“CT”),有些是带部位的组合词(如“胸部正位片”),这些对边界识别的难度更大。我在项目中针对这些低频形态补充了约100条标注数据,再训练后这一类实体的F1回升到了0.85以上。这个经验说明,只看整体F1远远不够,必须按实体的不同类型分别看指标,才能定位数据短板在哪,然后针对性地去补数据或者调规则。
4. 从实体识别结果到知识图谱构建的衔接
模型训练好了,工作并没有结束,模型的输出还只是一个个孤立的实体片段,要真正进入知识图谱,需要把这些实体转化成有结构的三元组,并完成实体对齐与去重。这一节是整个第六章最容易被人忽略、但在项目完整实现中最能体现工程能力的地方。
4.1 模型输出标准化与三元组构建
我在项目里约定模型的预测结果统一输出为JSON字典,字段包括sentence_id、mention、type、start_position、end_position、confidence。其中的mention是模型从原句中截取的原始文本片段,start和end是它相对原句的字符偏移量,confidence是CRF解码时该路径的置信度分数。这个标准化格式的一个好处是无论模型使用BILSTM+CRF还是后面的BERT变体,输出格式都可以保持一致,下游模块不需要跟着换模型重写。
三元组构建还需要关系抽取模块的配合,但实体识别的结果本身已经是关系抽取的重要输入特征了。以“高血压”和“硝苯地平”两个实体为例,关系抽取模块会以这两个实体在句子中的共现位置和上下文为输入,预测它们之间存在“治疗”关系。这个过程不是第六章的范畴,但我们在第六章就要注意:实体识别的置信度信息必须传递给下游。因为关系抽取模块可以通过实体置信度做加权判断,如果一个实体的置信度很低,它参与构成的关系三元组的可信度也会被降权,甚至直接被过滤掉。
4.2 实体对齐与去重处理
知识图谱中的实体对齐是一个容易被低估的难题。模型抽出来的实体是文本级别 mentions,比如“高血压病”和“高血压”属于同一个真实世界实体,如果直接把两个mention都写入图谱,会造成图里的节点数虚高、关系路径混乱。所以我在项目中设计了一个简单的基于归一化+相似度计算的去重步骤。归一化包括去掉常见修饰词(如“病”)、英文字母大小写统一、中文数字转阿拉伯数字。相似度计算使用编辑距离加字符集合重合度的组合,当相似度超过阈值时视为同一个实体,合并节点并保留高频出现的mention作为标准名。这个方案虽然比不上基于向量表示的专业实体链接系统,但在项目级应用里已经足够稳定,而且计算代价极低。
这里特别提醒一点:合并实体不能只看相似度,还要看类型。我在初期调试时只做了文本相似度,导致“感冒灵颗粒”(药物)和“感冒”(症状)被错误合并成一个节点,因为它们的文本字符有较高的重合度。后来加入“类型一致”的条件后,这种错误合并就消失了。这个细节说明知识图谱构建中,基于文本的相似度计算永远需要联合类型和属性信息一起判断,单纯靠字符层面的相似度会埋下很多隐蔽的坑。
5. 常见问题与调参避坑实录
这一节我整理了自己在BILSTM+CRF项目训练过程中陆续遇到的典型问题,按照症状、原因、解法三步列出来。这些问题在网上教程里很少会被集中提到,但几乎每个人在复现时都会跳出其中的一两个。
5.1 标签不均衡与“B后跟I”约束被破坏
实体识别任务里O标签通常占绝对多数,在一句话里可能只有三四个实体词,其余全是O。这个问题会导致模型倾向于把所有词都预测为O,尤其当训练语料规模只有几千条时。解决方法是先用数据统计确认每个标签的占比,如果O标签占比超过80%,就要考虑在损失函数中对不同类别的标签施加不同权重。PyTorch中可以直接在CrossEntropyLoss里传一个权重向量,把内部实体标签的权重调高到1.5到2.0,能很直观地缓解标签不均衡的影响。
还有一种很隐蔽的情况:模型的BILSTM输出部分虽然捕捉到了实体词的语义特征,但CRF层没有学到“B-symptom后面只能跟I-symptom或O,不能直接跟I-drug”这类约束。出现这个问题时,检查两个地方——第一是转移矩阵中从B到不同I标签的转移值是否明显区分,第二是训练数据中是否存在大量标注错误的跨类型实体标注。如果数据标注里本身有B-symptom后面跟着I-drug这样的错误样例,模型会被带偏。我们就遇到过一次标注人员把“头痛粉”(药物)标注成了症状类型,而且这个错误在几百条语料里反复出现,模型最后对“头痛粉”这种词变得非常敏感,总是抽成症状。后来把这一批标注数据抽出来重新人工检查,修掉错误标注后,模型的类型混淆问题就明显减轻了。所以出现问题的第一反应不应该是调参,而是回查数据。
| 问题现象 | 可能原因 | 处理优先级 |
|---|---|---|
| 实体识别结果中类型频繁错乱 | 标注数据存在跨类型错误 | 最高 |
| 所有句子预测几乎全为O | 标签严重不均衡,且无权重控制 | 高 |
| 频繁出现B后直接I跨类型 | CRF转移矩阵学习不充分,训练epoch过少 | 中 |
| 长句子末尾的实体漏检严重 | max_len截断导致尾部上下文丢失 | 高 |
5.2 序列长度与显存矛盾的处理
训练初期我直接把max_len设成了512,结果在GPU上训练时batch_size降到8才勉强跑起来,训练速度慢得让人受不了。但是max_len设成64又会截断掉不少长文本中的尾部实体信息。解决办法是先统计整个训练集的序列长度分布,用分布数据来决定max_len。在我们的医疗文本数据集中,95%的句子长度是在110个字符以内的,所以max_len定为128是最合适的比例。对于超过128的长文本,可以采用滑窗切分策略,把长句切成有重叠的两段分别预测,再对重叠部分的预测结果做投票融合,这种方案既控制显存开销,又不牺牲长文信息。
另一个费显存的大户是embedding层。如果词表已经很大(超过10万),Embedding层会占用大量显存,建议对低频字做截断处理,只保留词频前5万的字符,其余映射为UNK。这种近似在实体识别任务上损失很小,但能省下近一半的embedding参数存储量。
5.3 模型预测结果全为O的排查方法
模型训练完,测试阶段发现所有句子都只输出O标签,这在序列标注项目里是非常常见的翻车现象。第一反应是检查标签映射表,看看模型输出解码时用到的标签索引是否和训练时一致。我就在这个坑里跌过一次:训练时用了全局标签表,但推理脚本里重新构建了一个只包含数据中出现过的标签的映射表,而测试集里有一个实体类型在训练集中没有出现,导致这个类型的索引错位,最终解码出来的标签全乱了。排查方法很容易,写一段小代码把训练好的模型在10条训练集数据上跑一遍,如果连训练数据都预测不对,那就不是泛化问题,而是代码链路或者数据对不上,按这个思路可以快速缩小排查范围。
还有一种可能性是CRF层的转移矩阵虽然学了,但发射分数整体偏低,导致模型更偏爱转移到O标签。可以选择将标签权重调高并且增加训练轮数,或者调整CRF初始转移矩阵,提高B标签的初始转移分数,让模型不要一开始就放弃可能包含实体的路径。
5.4 训练时间长到不能忍受的提速方案
在没有GPU的机器上训练BILSTM+CRF确实非常煎熬,一个epoch要跑十几分钟,40轮训下来要半天甚至更久。第一个提速点是用梯度累积替代增大batch_size,例如把batch_size从16提到32,如果没有足够显存,就在16的batch下累积两步再更新一次梯度,效果几乎等同于batch_size为32的训练,而显存不变。第二个提速点是尽量减少padding冗余,当batch内句子长度差异很大时,padding带来的无效计算也会白白消耗大量时间。我们可以在数据加载器里自定义每个batch按长度倒序排列,并在动态计算最大长度时使用该batch内的实际最长长度,而不是全局max_len。这项改动能将训练速度提升20%至30%,在长尾分布的数据集上提升更明显。
第三个提速点是检查是否无意中使用了过大的LSTM隐层维度。有些同学照搬文本分类模型的配置,把LSTM隐层设为512,但序列标注任务需要逐字输出,512维的双向LSTM在计算量上比分类任务大得多。在语料只有几万条时,256维是性能与速度的合理平衡点,再往上调的收益非常有限。
写在最后的经验
跑完这一整套BILSTM+CRF项目,我最大的感受是,实体识别模型的效果上限通常不取决于模型的复杂程度,而取决于数据质量以及对数据的理解深度。我们前前后后调了很多轮参数,一次把F1从0.84提到0.87的关键动作不是修改网络结构,而是把训练集里某个类型的标注噪声清理干净。所以在大家拿到模型跑通之前,我建议先花时间统计一下自己的数据分布,看清楚每一类实体的样本总量、长度分布和书写规律,再动手训练。模型跑通之后的预测结果,也要留出一部分人工抽检的样本,直接看错在哪里,因为只看指标找不到方向,人工看几十条错误样例往往就能发现明确的规律。这套方法在后续复杂模型上同样适用。本章结束后,下一步有能力继续做的扩展是把命名实体识别升级为端到端的关系抽取,让模型直接输出三元组,那样整个知识图谱构建的自动化程度就能更进一步了。