ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

BERT+BiLSTM+CRF实现医学命名实体识别与知识图谱构建

2026/9/20 23:52:35 拓冰建站 浏览量
BERT+BiLSTM+CRF实现医学命名实体识别与知识图谱构建 简介面向自然语言处理与医学信息挖掘场景这份融合BERT、BiLSTM与CRF的医学实体识别项目资源适合NLP学习者、医疗文本处理工程师及知识图谱研究者参考。压缩包共1162个文件、约25MB涵盖510个txt原始语料、449个ann标注文件、131个json配置与结果文件以及40个py脚本和11个ipynb交互式Notebook另有shell脚本、说明文档等可完整复现从数据预处理、模型训练到实体抽取的完整流程。项目清晰展示了BERT提取深度语义、BiLSTM捕捉序列上下文、CRF优化标签一致性的协同架构并附有医学知识图谱构建的步骤与示例帮助读者直接跑通代码并理解实体识别在实际医疗文本中的落地细节。目前已有680人学习下载适合希望系统掌握医学NER建模并快速构建知识图谱的开发者。1. 别让命名实体识别卡在“分词”这一步医学文本里一个实体可以是两个字“肺癌”也可以是七个字“非小细胞肺癌”甚至嵌套着另一个实体“左肺上叶腺癌”里既有部位又有病理类型。通用 NER 工具在这类文本上表现不佳的主要原因不是模型不够深而是把“序列标注”当成了“分词加分类”的串联任务。BERTBiLSTMCRF 的组合解决的是同一个问题如何在字级别上同时捕捉上下文语义、局部依赖和标签之间的强约束关系最终把“哪几个字组成一个实体、这个实体属于什么类型、实体之间能否重叠”一次性求解出来。搭建医学知识图谱的前置步骤就是实体识别这一步的质量直接决定图谱的节点数量和关系质量。适合正在做医疗文本结构化、病历信息抽取、医学文献挖掘以及需要把非结构化临床文本转化成可查询知识的工程人员。2. 结构拆解BERT 负责语义BiLSTM 负责局部特征CRF 负责标签约束这一章把模型的三个组件按职责拆开讲清楚。很多工程师第一次接触这个组合时会误以为三层是串联的“黑盒”但实际上每层解决的是不同粒度的建模问题。2.1 BERT 层为什么不用 Word2Vec 或 ELMo在医学领域一词多义现象非常严重。“浸润”在影像报告里是“浸润性癌”的高危信号在病理报告里可能描述“炎症细胞浸润”在手术记录里又可能是“浸润麻醉”。Word2Vec 训练出的静态词向量无法区分这些语义差异因为无论上下文怎么变“浸润”的向量永远只有一个。ELMo 虽然能做到上下文相关但它使用的是双向 LSTM 的隐层拼接对长距离依赖的建模能力有限。BERT 的核心优势是 Transformer 编码器中的 self-attention 机制每个 token 的表示会直接关注句子中所有其他 token医学文本中常见的“尽管患者无发热但影像学提示炎症浸润”这类转折关系BERT 能通过 attention 权重捕捉到“无发热”和“炎症浸润”之间的语义距离。在具体实现上BERT 输出的last_hidden_state形状为[batch_size, seq_len, hidden_size]其中hidden_size在 base 版本中是 768这一层输出的是每个字符或 BPE 子词的上下文向量。在参数选择上有两个关键点参数推荐值说明max_length128-256医学文本句子长度分布不均超过 256 的句子建议截断或滑窗切分modelbert-base-chinese中文医学文本的通用起点若数据量充足可继续预训练output_hidden_statesFalse只取最后一层输出无需开启全部隐层状态节省显存2.2 BiLSTM 层在 BERT 之上的序列上下文建模BERT 已经建模了全局上下文为什么还要再接一层 BiLSTM原因有两个一是 BERT 的输入是子词subword而医学实体识别需要的是字级别的超平面分割BiLSTM 可以对 BERT 的输出做一次序列压缩和特征筛选二是 BiLSTM 可以学习到“实体边界”的隐式模式比如医学文本中频繁出现的“左/右/上/下”方位词后接解剖部位的概率极高。BiLSTM 的计算流程是将 BERT 输出的每个 token 向量序列分别输入正向 LSTM 和反向 LSTM正向学习从句子开头到当前的依赖反向学习从句子结尾到当前的依赖然后把两个方向的隐状态拼接。对应到 PyTorch 代码中nn.LSTM(..., bidirectionalTrue)的输出维度是hidden_size * 2这也就是为什么后面接 CRF 层时线性变换的输入维度要设置为hidden_size * 2。这里的 LSTM 隐藏层维度一般设为 128-256 之间。太小会丢信息太大在训练集只有几万条医学文本时容易过拟合。我有一个常用的做法当 BERT 参数量是 102M 时BiLSTM 的隐藏层设为 128 即可此时 CRF 层接收的是 256 维的特征向量参数量增长不大但训练速度明显快于直接接全连接层。2.3 CRF 层让标签序列合法化CRF 层解决的核心问题是“标签之间的转移约束”。BIO 标注体系下O - I-疾病是非法转移没有 B 直接进入 IB-疾病 - I-症状也是非法转移不同实体类型之间不能连续。一个独立的 softmax 分类器不会感知到这些约束它只会对每个位置生成独立的概率分布最终可能解码出一个“解剖部位和症状交错出现”的荒谬序列。CRF 的原理是把整套标签序列的得分定义为“发射分数加转移分数之和”然后通过维特比算法找全局最优解。PyTorch 中没有内置 CRF 层工程上常用torchcrf或自己实现一个核心逻辑如下class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags # 转移分数矩阵transitions[i][j] 表示从标签 i 转移到标签 j 的得分 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 设置 START 和 END 标签约束 self.start_transitions nn.Parameter(torch.randn(num_tags)) self.end_transitions nn.Parameter(torch.randn(num_tags)) def forward_alg(self, emissions, mask): # emissions: [batch_size, seq_len, num_tags] batch_size, seq_len, _ emissions.shape init self.start_transitions emissions[:, 0, :] forward_var init.unsqueeze(1) # [batch, 1, num_tags] for i in range(1, seq_len): # [batch, num_tags, num_tags] scores forward_var self.transitions.unsqueeze(0) emissions[:, i, :].unsqueeze(1) forward_var torch.logsumexp(scores, dim-1).unsqueeze(1) terminal_var forward_var.squeeze(1) self.end_transitions return torch.logsumexp(terminal_var, dim-1)forward_alg实现的是前向算法计算所有可能标签序列的 log 概率之和用于训练时的归一化。CRF 的训练目标是最大化正确标签序列的概率需要配合负对数似然损失使用。它的参数量很小只有num_tags * num_tags一个矩阵加上 start/end 两个向量。2.4 完整的联合训练流程在工程实现中BERT、BiLSTM、CRF 三层是联合训练的不是逐层预训练。整体损失函数只包含 CRF 的负对数似然BERT 和 BiLSTM 的参数通过反向传播同时更新。数据流转路径原始文本 → BERT tokenizer → token 嵌入 → Transformer 编码 → 上下文向量 → BiLSTM 正向/反向编码 → 特征拼接 → 全连接降维 → CRF 解码 → 标签序列。训练时的 mask 非常关键。BERT tokenizer 会把中文句子拆成单字但在遇到英文药物名、剂量数字时会拆成子词例如“阿莫西林胶囊500mg”中的“500”和“mg”可能被拆成[500]和[mg]两个 token。此时需要保证 label 对齐的是 BERT 子词级别的序列而不是字符级别的序列。attention_mask要同时传给 BiLSTM 和 CRF 层否则 padding 位置会参与解码并干扰转移概率的学习。3. 从数据集到训练脚本最小可复现的医学 NER 搭建过程这一章给出一套不依赖任何私有数据集的完整落地方案用自己的病历文本也能跑通。整个流程分为数据集准备、预处理、模型搭建、训练配置四步。3.1 数据集格式与标注规范医学 NER 的常见数据类型有入院记录、出院小结、影像报告、病理报告。推荐使用 BIOBegin, Inside, Outside标注体系实体类型建议控制在 5-8 类以内过多会让 CRF 层学到不可靠的转移关系。我常用的一套医学实体类型如下实体类型示例BIO 标签疾病非小细胞肺癌B-疾病, I-疾病, I-疾病, I-疾病, I-疾病症状刺激性干咳B-症状, I-症状, I-症状, I-症状药物吉非替尼B-药物, I-药物, I-药物, I-药物检查胸部增强CTB-检查, I-检查, I-检查, I-检查部位左肺上叶B-部位, I-部位, I-部位, I-部位手术胸腔镜下肺段切除术B-手术, I-手术, I-手术, I-手术, I-手术, I-手术标注工具可以用 Label Studio 或 doccano输出 JSON 格式。需要注意的一个陷阱是医学文本经常出现“边缘模糊”这类描述性短语不要把它标注成实体它是影像特征不是可概念化的医学实体。标注规范需要在标注前充分对齐否则模型会学到不一致的边界信息。3.2 预处理处理长文本、嵌套实体和大小写干扰预处理环节不只是调用 tokenizer 那么简单。医学文本有三个现实问题超长句子、嵌套实体、全半角混用。第一超长文本。一份出院小结可能包含几百个句子BERT 的输入长度限制是 512超出部分必须处理。常见做法是按句子切分然后以 128-256 token 为窗口做滑窗窗口重叠 16-32 个 token并把子句之间的实体边界保留在标注中。注意不要在窗口切分处切断一个实体预处理脚本需要检测窗口边界是否落在实体内部如果是就偏移一个 token。第二嵌套实体。“左肺上叶腺癌”中“左肺上叶”是部位“腺癌”是疾病整体又是一个诊断。BIO 标注无法表示嵌套关系一个 token 只能打一个标签。这是 BERTBiLSTMCRF 方案的已知局限。解决方法是要么在预处理阶段将嵌套实体拆开用两个独立序列标注任务层叠训练要么在损失函数中加入边界回归形成实体边界和实体类型的多任务结构。工程初期建议直接拆开先保证每个实体是平铺结构。第三大小写和全半角。英文药物名的写法不稳定比如“TACE”可能被写成“Tace”或“tace”在预处理阶段统一转为大写数字和单位之间补空格避免 BERT tokenizer 错误地合并它们。下面是一个标准的预处理流程from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(bert-base-chinese) labels_list [O, B-疾病, I-疾病, B-症状, I-症状, B-药物, I-药物] label2id {label: idx for idx, label in enumerate(labels_list)} max_len 128 def encode_text_with_labels(text, ori_labels): enc tokenizer(text, truncationTrue, max_lengthmax_len, paddingmax_length, return_offsets_mappingTrue) input_ids, attention_mask enc[input_ids], enc[attention_mask] offsets enc[offset_mapping] label_ids [label2id[O]] * len(input_ids) for idx, (start, end) in enumerate(offsets): if start end: # special token如 [CLS]、[SEP]、[PAD] continue # 根据 token 的字符偏移范围映射对应的原始标签 for label_id, char_id in enumerate(range(start, end)): if label2id.get(ori_labels[char_id]) is not None: label_ids[idx] label2id[ori_labels[char_id]] return input_ids, attention_mask, label_ids这段代码中return_offsets_mappingTrue返回的是每个 token 对应原始文本的字符区间这是对齐 BERT 子词与原始标签的关键。直接按字符位置遍历赋值会出错因为 BERT 可能把一个连续字符序列拆成多个 token而 tokenizer 的offset_mapping才是唯一准确的映射依据。3.3 模型定义把三个组件拼成一个类承接前文的架构拆解下面是核心的模型类定义。PyTorch Lightning 封装了全套训练逻辑这里用纯 PyTorch 做更直接的展示import torch import torch.nn as nn from transformers import BertModel class BertBiLstmCrf(nn.Module): def __init__(self, bert_name, num_tags, lstm_hidden128): super().__init__() self.bert BertModel.from_pretrained(bert_name) self.lstm nn.LSTM(input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(0.3) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tagsnum_tags) # 前文定义的 CRF 类 def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) # last_hidden_state: [batch, seq_len, 768] sequence_output outputs.last_hidden_state lstm_out, _ self.lstm(sequence_output) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) return emissions def loss(self, input_ids, attention_mask, label_ids): emissions self.forward(input_ids, attention_mask) return -self.crf(emissions, label_ids) # 负对数似然越小越好 def decode(self, input_ids, attention_mask): emissions self.forward(input_ids, attention_mask) return self.crf.decode(emissions, attention_mask)CRF类的decode方法用维特比算法查找最优路径它返回的是标签索引序列。这里num_tags要和label2id的长度完全一致。attention_mask在这里有两个作用BERT 用它屏蔽 padding token 的 attentionCRF 用它跳过填充位置的转移计算。3.4 训练配置和四个关键超参数训练医学 NER 模型时学习率和 warmup 比例最容易出错。BERT 微调的典型学习率是2e-5到5e-5但 BiLSTM 和 CRF 层建议使用稍大的学习率因为它们的参数没有经过预训练初始状态和 BERT 不在同一个量纲上。可以设置分组学习率BERT 层2e-5BiLSTM全连接CRF 层1e-3。超参数建议值影响learning_rate2e-5 (BERT) / 1e-3 (下游)过大会导致预训练表示被破坏warmup_ratio0.1前 10% 的 step 逐渐升温稳定微调batch_size8-16显存允许时尽量大稳定 BN 的统计量max_epochs5-8医学数据小、噪声高超过 10 轮容易过拟合优化器推荐用 AdamWweight_decay设为0.01。早停策略用验证集 F1 做监控patience 设为 2。这里有一个来自实践的提示如果训练集不足 5000 条不要直接微调整个 BERT冻结 BERT 的前 6 层只训练后 6 层和下游结构效果明显更好。4. 从实体到知识图谱构建与查询一条线打通NER 模型输出的是一堆散落的实体标签要变成知识图谱还需要完成实体对齐、关系抽取和图数据库导入三件事。强烈不建议使用 python 的字典结构存储图谱在百级实体规模下还能工作一旦到万级节点查询性能和关系遍历会直接罢工。4.1 实体抽取结果的后处理模型输出的原始标签序列需要进行实体合并。这一步骤容易出错的地方在于BERT 的 tokenizer 会把连续字切碎所以合并时要回到原始文本的字符区间而不是在 token 层面直接拼接。后处理流程如下def extract_entities(text, label_ids, tokenizer): tokens tokenizer.tokenize(text, truncationTrue, max_length128) entities [] current_entity None for idx, (token, label) in enumerate(zip(tokens, label_ids)): if label O: if current_entity: entities.append(current_entity) current_entity None elif label.startswith(B-): if current_entity: entities.append(current_entity) current_entity {type: label[2:], text: token.replace(##, ), start: idx} elif label.startswith(I-): if current_entity and current_entity[type] label[2:]: current_entity[text] token.replace(##, ) else: # I 前没有同类型 B_ 属于解码错误丢弃该 token current_entity None if current_entity: entities.append(current_entity) return entities这段后处理中的I判断条件很重要如果I-疾病前一个实体不是同类型说明 CRF 解码出现非法转移此时直接丢弃这个 token不要强行拼接。合并后的实体需要做标准化把“肺腺癌”“肺腺癌(IV期)”中的分期信息剥离保留规范化名称“肺腺癌”。4.2 实体对齐到统一术语集实体归一化是医学知识图谱中最消耗人力的部分。没有标准术语集的医学图谱必然是混乱的因为“肺腺癌”“肺癌”“非小细胞肺癌”在文本中指代相近但语义层次不同。常见做法是使用 UMLS 或 ICD-10 作为统一术语集将抽取出的实体映射过去。映射策略有两条路。第一条是规则映射把“肺癌”等高频短语做精确匹配或字面相似度匹配用rapidfuzz的fuzz.token_sort_ratio做模糊匹配阈值设为 90 以上速度快适合高频实体。第二条是语义映射将实体文本输入 BERT 做句向量编码和 UMLS 中的候选概念向量求余弦相似度阈值一般设 0.82-0.85适合规则匹配不到的复杂实体。实现层面SQLite 表结构是通用选择CREATE TABLE entity_concept ( source_text TEXT NOT NULL, standard_name TEXT NOT NULL, cui TEXT NOT NULL, entity_type TEXT NOT NULL, confidence REAL NOT NULL ); CREATE INDEX idx_entity_type ON entity_concept (entity_type);这一步建议保留置信度字段后续图谱展示时可以过滤置信度低于阈值的边避免“幻觉节点”污染图谱。4.3 关系抽取与图谱写入实体识别完成后还需要确定“实体之间有什么关系”否则图谱只是一个孤立节点集合。医学文本中最常见的关系有五类疾病-有症状-症状、疾病-部位-解剖部位、药物-治疗-疾病、检查-检出-疾病、手术-针对-疾病。规则关系抽取方式是在同一句子中如果出现两个实体且两者之间出现触发词“表现为”“提示”“考虑”“可见”则建立对应关系。关系实例的构建和数据入库可以用 Python 脚本完成以 Neo4j 为例Cypher 中MERGE是防止重复节点和边的不二选择from py2neo import Graph graph Graph(bolt://localhost:7687, auth(neo4j, password)) def insert_entity(entity, type): graph.run( MERGE (e:Entity {name: $name, type: $type}), nameentity, typetype ) def insert_relation(subj, subj_type, rel_type, obj, obj_type): graph.run( MATCH (s:Entity {name: $subj}) WITH s MATCH (o:Entity {name: $obj}) MERGE (s)-[r: rel_type ]-(o) , subjsubj, objobj )Cypher 用rel_type动态拼接存在注入风险生产环境需要用apoc.create.relationship配合参数传入。另外MERGE默认是“无则建有则匹配”配合CREATE INDEX提升匹配速度否则节点到几万级别后MATCH速度会降到秒级以下。4.4 图谱查询一个可以检验整个流程的模板图谱写完后的第一条查询语句建议是“某疾病关联的所有药物、症状和手术”它能验证节点类型和关系方向是否一致。常用的查询语句如下MATCH (d:Entity {name: 肺腺癌, type: 疾病})-[r]-(n:Entity) WHERE type(r) IN [治疗, 有症状, 针对] RETURN d.name, type(r), n.name, n.type ORDER BY n.typetype(r)用来动态显示关系类型ORDER BY n.type让结果按实体类型排列方便人工核对。实际案例中这条查询返回的数据会直接暴露实体对齐的问题比如“肺腺癌”和“非小细胞肺癌”被抽成了两个节点说明归一化步骤中语义映射的阈值设得太保守了。5. 验证和排错模型跑通了不代表能上线NER 模型训练到 95% 准确率、图谱也打通了距离生产仍有最后一段路这段路通过“坏 case 驱动”来走完。泛化能力需要靠验证集来评估但上线前要做的不是只看 F1 数值而是要逐条检查模型在“最长尾”输入上的表现。验证维度关注点具体操作建议实体边界是否偏大或偏小“左肺下叶背段结节”被识别为“左肺下叶”还是“左肺下叶背段”专门统计 I 标签占比确认模型是否在长实体上过早终止嵌套实体是否被拆散“EGFR 基因突变型肺腺癌”的突变类型没有被识别增加实体类型“基因突变”并加入辅助训练数据否定词是否误判“未见明确淋巴结肿大”中“淋巴结肿大”错误识别为症状生成否定焦点的对抗样本“未见”“无”“排除”在验证集合中加入至少 50 条标点和遮挡干扰中文全角括号和半角括号混用导致实体截断上线前做文本规范化转为全角中文标点排错阶段一个特别好用的工具是 CRF 的维特比解码轨迹。直接打印每个 token 的 BERT 输出概率分布前五名和最终解码路径能看到模型在哪个位置发生了标签跳转。比如某个“疾病”被预测成“症状”通常是这一类实体在训练集中出现频率太低CRF 的转移矩阵学到的是“疾病后面更倾向于跟部位”而非“疾病后面还是疾病”。调整方法不是补样本而是先看训练集中疾病实体的平均长度如果多数是单个 token模型自然会倾向于把长疾病实体拆碎。最后一个值得掌握的排查思路把 BERT 输出的实体概率分布和 BiLSTM 层的输出做差分。当 BERT 对某个 token 的两个候选标签比如疾病和症状输出概率非常接近时BiLSTM 是否有压低其中一个候选的行为。如果 BiLSTM 的隐层规范范数过小说明它没能有效利用序列上下文的局部特征此时把lstm_hidden从 128 提升到 256往往能同时压低几个坏 case 的错误概率。对比实验的收益可以这样验证对同一份测试集分别跑纯 BERTCRF 和 BERTBiLSTMCRF逐条记录 F1 差异观察 BiLSTM 在哪些实体类型上贡献最大。这个差异分布将直接决定是否会选择 BERTCRF 的轻量方案还是坚持完整的三层架构。本文还有配套的精品资源点击获取