
简介一份结合BERT、BiLSTM与CRF的医学实体识别与知识图谱构建资源面向自然语言处理学习者和医疗信息化开发者。内容围绕医学文本中疾病、症状、药物等实体抽取展开覆盖数据预处理、模型搭建、训练调优、实体识别、关系抽取到图谱构建的完整链路。压缩包共1162个文件约25.18MB核心包括大量txt医学文本与ann标注数据、JSON处理结果、Python训练与推理脚本、Jupyter Notebook演示、PDF说明文档以及依赖安装脚本、wheel包等辅助材料目录结构清晰便于对照复现和二次开发。已有680人学习适合希望快速上手医学NLP项目、深入理解BERTBiLSTMCRF组合策略的读者。借助该资源可系统掌握深度语义表示、双向上下文建模与条件随机场标签约束的协作机制并利用现成数据与代码搭建自己的医学知识图谱。1. 从病历文本到知识图谱临床实体识别为何非BERTBiLSTMCRF不可临床医生写一份出院小结通常只需要五分钟但要把这段半结构化文本转成计算机可计算的疾病、药物、症状三元组却常常耗费算法工程师一下午。直接跑现成的通用NER工具像LTP或Stanford NER在医学文本上大多只能达到70%出头的F1值因为医学实体边界模糊、缩写密集、嵌套现象严重。把BERT的深度语义表征、BiLSTM的序列依赖捕捉和CRF的全局标签约束叠在一起是目前在标注数据有限条件下兼顾精度与工程实现成本最稳妥的方案。这套架构对五年以上NLP工程师的意义在于它不是简单把三个模型串起来而是要在嵌入层、编码层和解码层分别处理医学文本特有的问题——术语边界漂移、类别不平衡和标签依赖。本文直接拆解这套架构从预训练模型加载到KG落库的完整实现路径不回避参数配置细节和训练坑点。2. BERT临床语义嵌入层医学文本微调的三个关键决策2.1 选择哪个预训练模型作为底座通用中文BERT在医学实体上表现不佳根源在于预训练语料里医学文本占比太低导致MASK语言模型对“间质性肺炎”、“盐酸二甲双胍缓释片”这类术语的上下文预测几乎是瞎猜。常见做法是选用在医学语料上继续预训练的模型。如果你手头机器资源允许推荐使用bert-base-chinese基础上用医院真实病历继续预训练3个epoch这比直接换BioBERT更适配中文电子病历。from transformers import BertTokenizer, BertForTokenClassification tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForTokenClassification.from_pretrained( bert-base-chinese, num_labelslen(label2id) )这里有几个关键参数要注意。num_labels必须是你的标签集合大小包括O标记否则CRF层的计算维度对不上。医学NER一般用BIO或BIOES标注体系BIOES在医学场景下效果更稳定因为它对实体边界的描述更细后期接关系抽取时边界信息更可靠。2.2 文本截断和Token级别对齐的隐患BERT的最大输入长度是512个token但医学病历中一段病史描述经常超过这个长度。简单截断会直接把实体从中间切断导致标注错位。我一般会在数据预处理阶段做两件事一是按句子切分而不是整段截断二是对超过510个token的长句使用滑窗重叠加截断窗口大小设128、步长设64确保跨窗口的实体至少完整出现在其中一个窗口里。def split_long_text(text, labels, max_len510, stride64): tokens, label_ids tokenizer.encode( text, is_split_into_wordsFalse), labels if len(tokens) max_len: return [(tokens, label_ids)] chunks [] start 0 while start len(tokens): end start max_len chunk tokens[start:end] chunk_labels label_ids[start:end] chunks.append((chunk, chunk_labels)) if end len(tokens): break start end - stride return chunks这段代码的核心逻辑不是简单切片而是保留了stride大小的重叠区域。因为BERT的tokenizer会把一个词切成多个subword如果直接在字符级别切片会导致切出来的序列里包含半个词交给CRF计算时标签就全乱了。我一般会额外维护一个word_ids映射表把subword对应的标签对齐到第一个subword上后续的subword标签统一设为-100这样在计算loss时这些位置就会被忽略。2.3 微调超参数与学习率分层策略BERT微调最大的坑在于学习率。直接使用全量5e-5会让底层Transformer的预训练知识迅速被冲掉而上层随机初始化的分类头又学习太慢。我一般会将模型拆成三层分别设置学习率BERT主干保持2e-5BiLSTM层设1e-3CRF层设1e-2这样底层语义特征基本不动上层结构加速收敛。参数推荐值说明主干学习率2e-5微调BERT底层通用语义编码层学习率1e-3训练BiLSTM序列特征提取解码层学习率1e-2CRF转移矩阵需要更快收敛warmup比例0.1前10%步数线性热身稳定底层batch_size16显存不够时用梯度累积替代3. BiLSTM序列编码层上下文特征再提取的必要性与工程实现3.1 为什么BERT后面还要接BiLSTM有人质疑BERT本身已经有了Transformer的双向注意力机制为什么还要再叠BiLSTM做重复的特征提取。这个问题的答案在序列长度上。BERT自注意力机制的感受野是全局的但医学实体识别的标签分配更多依赖局部窗口——一个实体内部的词和紧邻的修饰词之间的依赖模式其实比跨句的远程依赖更重要。BiLSTM在这里扮演的角色不是全局建模而是把BERT输出的深度语义向量进一步压缩成有序的序列特征流。import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, input_dim, hidden_dim, dropout0.5): super(BiLSTMEncoder, self).__init__() self.lstm nn.LSTM( input_dim, hidden_dim, num_layers2, bidirectionalTrue, batch_firstTrue, dropoutdropout ) def forward(self, bert_outputs, mask): # bert_outputs: (batch, seq_len, 768) lengths mask.sum(dim1).cpu() packed nn.utils.rnn.pack_padded_sequence( bert_outputs, lengths, batch_firstTrue, enforce_sortedFalse ) packed_out, _ self.lstm(packed) lstm_out, _ nn.utils.rnn.pad_packed_sequence( packed_out, batch_firstTrue ) return lstm_outhidden_dim的取值直接决定模型容量。医学实体识别任务中hidden_dim太少实体边界信息会被压扁太大则容易过拟合。我一般取256到384之间。pack_padded_sequence和pad_packed_sequence是必须的操作因为batch内每个样本长度不同不pack的话LSTM会把padding位置也当作有效输入计算造成序列依赖污染。3.2 残差连接与LayerNorm的必要性直接拿BERT输出接LSTM会有一个问题BERT的输出空间和LSTM期望的输入空间分布差异大LSTM首层的梯度更新容易震荡。最常见的做法是在LSTM输出后加LayerNorm若维度不一致则加线性投影层。class ProjectedLSTM(nn.Module): def __init__(self, bert_dim768, hidden_dim256, num_labels7): super(ProjectedLSTM, self).__init__() self.proj nn.Linear(bert_dim, hidden_dim * 2) self.norm nn.LayerNorm(hidden_dim * 2) self.lstm nn.LSTM( hidden_dim * 2, hidden_dim, bidirectionalTrue, batch_firstTrue ) def forward(self, bert_outputs, mask): proj self.norm(torch.relu(self.proj(bert_outputs))) return self.lstm(proj)注意hidden_dim * 2的用法LSTM双向输出维度是2 * hidden_dim但输入维度不一定要是hidden_dim * 2。这里我把BERT的768维投影到512维再喂给BiLSTM最后输出也是512维。这样做的目的是让BiLSTM的输入和输出维度一致方便后面CRF层直接接全连接分类器。3.3 医学文本中的序列长度分布与性能权衡医学病历中句子长度偏态分布严重有的检查报告一句话只有七八个字但影像所见段落能有300多个字。静态长度截断策略会造成大量信息丢失。我一般会在训练前统计训练集句子长度的95%分位数以这个值作为截断上限而不是硬套512。lengths [len(tokenizer.encode(s)) for s in train_sentences] p95 sorted(lengths)[int(len(lengths) * 0.95)]小于这个上限的句子直接用于训练超过的才走滑窗切分策略。这样做的好处是大多数样本保持完整语义和标签对齐只有少数超长样本做滑窗处理训练速度几乎不受影响。4. CRF解码层全局最优序列标注与转移矩阵的工程实现4.1 从逐点分类到序列最优分配BiLSTM输出每个token属于各个实体类别的发射分数emission score但这个分数是逐token独立计算的没有考虑标签之间的依赖关系。CRF层补的正是这一环——通过一个可学习的转移矩阵来约束标签转移路径。例如B-Disease后面不能接I-DrugI-Symptom前面必须有一个B-Symptom或I-Symptom。这些约束在通用NER里是基础规则在医学上下文里更加严格。class CRFLayer(nn.Module): def __init__(self, num_labels): super(CRFLayer, self).__init__() self.num_labels num_labels self.transitions nn.Parameter( torch.randn(num_labels, num_labels) ) # 约束START和END标签不转移 self.transitions.data[:, 0] -10000 # 不能转移到CLS self.transitions.data[0, :] -10000 # CLS不能转移出去 def forward(self, emissions, mask): # emissions: (batch, seq_len, num_labels) # 使用前向后向算法计算对数似然 pass转移矩阵的含义是transitions[i][j]等于从标签i转移到标签j的得分。负无穷大的位置就是禁止转移路径。在医学实体识别里自定义这些rule-based约束很有价值。比如我通常会禁止B-Position解剖部位转移到I-Drug药物这种约束配合CRF的维特比解码能把一些语义模糊的边界直接封死。4.2 维特比解码与O标签的默认陷阱推理阶段使用维特比算法求解全局最优标签序列。维特比算法的核心是动态规划——维护一个路径概率矩阵逐步扩展保存最优路径。def viterbi_decode(self, emissions, mask): batch_size, seq_len, num_labels emissions.shape all_paths [] for b in range(batch_size): score emissions[b, 0] backpointers [] for t in range(1, seq_len): if mask[b, t] 0: continue next_score score.unsqueeze(1) \ self.transitions emissions[b, t].unsqueeze(0) best_score, best_label next_score.max(dim1) backpointers.append(best_label) score best_score path [score.argmax().item()] for bp in reversed(backpointers): path.insert(0, bp[bp[path[0]]].item()) all_paths.append(path) return all_paths注意维特比解码中mask参数的作用。padding位置不参与路径计算但在反向回溯时也要跳过否则会把padding位置的标签错误拼接进序列。我在这部分调试时最容易踩的坑是训练时用了ignore_index-100但推理时忘了处理这个值导致CRF的转移矩阵中把-100当成一个合法标签参与计算最后decode出来的路径全是乱的。4.3 转移矩阵初始化的医学先验知识融合数据量不足时让模型自己学转移矩阵容易出现过拟合。我一般会在初始化阶段手动注入约束规则先统计训练集里标签转移的真实概率作为先验然后对某些概率为0的转移设置一个较大的负值惩罚而不是完全禁止。prior_trans build_prior_from_train_set(train_labels) transitions nn.Parameter(torch.log(prior_trans 1e-10)) # 对于从未出现的转移设置较小约束 transitions.data[transitions.data -5] -5这样做的好处是模型既能快速收敛到合理的转移模式又保留了少数边界情况调整的空间。对于医学知识图谱构建实体边界错误的代价比类别错误的代价更高——因为KG构建是下游任务边界错了整个三元组都会失效。5. 从标注序列到知识图谱三元组构建与医学场景下的关联规则实体识别完成只是第一步后面要把标注序列转成可查询的图结构。常见做法是采用(头实体, 关系, 尾实体)三元组形式但医学场景下的关系抽取比通用领域更复杂。class MedicalEntity: def __init__(self, text, label, start, end): self.text text self.label label self.start start self.end end def extract_triples(entities, relations): triples [] for rel in relations: head next((e for e in entities if e.label rel.head_type), None) tail next((e for e in entities if e.label rel.tail_type), None) if head and tail: triples.append((head.text, rel.type, tail.text)) return triples抽取后还需要合并基于规则和基于模型抽取出来的实体。常见做法是用Neo4j的MERGE语句写入先查重后插入避免重复实体造成图结构冗余。MERGE (d:Disease {name: 糖尿病}) MERGE (s:Symptom {name: 多饮}) MERGE (d)-[:HAS_SYMPTOM]-(s)在医学知识图谱的实际落地中唯一要注意的是实体对齐问题。同样的“二甲双胍”病历里有“盐酸二甲双胍”、“二甲双胍片”、“格华止”三种写法需要做实体归一化。我一般会维护一个标准术语表把模型识别出的实体文本统一映射到UMLS或ICD-10编码上这样图谱中的节点才有明确的语义指向下游的临床决策支持系统才敢直接调用。另一个实战技巧是把CRF解码得到的实体边界信息作为关系抽取模型的输入特征而不是只用实体文本本身。边界概率低接近0.5的token往往是实体混淆区保留这个置信度信息能帮关系分类模型过滤掉很多模糊匹配。本文还有配套的精品资源点击获取