ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

医学知识图谱构建:BERT+BiLSTM+CRF实体识别到Neo4j落地

医学知识图谱构建:BERT+BiLSTM+CRF实体识别到Neo4j落地 简介这份资源面向自然语言处理与医学知识图谱方向的开发者围绕BERTBiLSTMCRF联合模型给出从医学文本中识别疾病、症状、药物等实体的完整方案。资源将预训练语义表示、双向序列特征与条件随机场标签约束结合并覆盖数据准备、模型构建、实体识别、关系抽取、知识图谱构建及应用服务的实现路径。压缩包共1162个文件大小25.18MB以txt与ann标注语料为主包含510个txt和449个ann便于查看实体边界与标注规范另有131个json、40个py、11个ipynb分别对应配置数据、训练脚本和实验笔记少量pdf/md/sh/whl提供说明与环境支持。已有680人学习下载适合需要复现医学实体识别项目、搭建知识图谱的初学者或工程师。通过代码、标注语料和目录化资料可快速跑通模型流程并参考关系抽取与图谱构建逻辑扩展到自己的医学文本数据集。1. 医学实体识别为什么值得做成知识图谱拿到一份出院小结上面写着“患者因冠心病长期口服阿司匹林近期出现胃肠道出血”。如果只做命名实体识别你能抽出“冠心病”“阿司匹林”“胃肠道出血”三个实体但不知道谁诱发谁反过来如果不做实体识别直接建图谱里面连像样的节点都凑不齐。BERTCRFBiLSTM正是命名实体识别里最稳的组合——它把句子里每个token的标签一次解出来知识图谱再把这些标签对应的实体连成网络并支撑查询。这篇笔记从标注格式、训练代码一路写到把实体导入Neo4j构建医学知识图谱适合手里有医学文本、想搭科室级知识库的算法和工程同学照着做。2. 拆解BERTBiLSTMCRF三个组件在实体识别里各扛什么活2.1 BERT层动态语义表征是实体边界的基础词向量最被诟病的一点是静态每个token只有一个固定向量“阿司匹林”在“阿司匹林肠溶片”和“阿司匹林哮喘”两个语境里拿到的是同一个向量模型分辨不了差异。BERT的self-attention机制让每个token的向量都融合了整句话的信息同字不同义的现象被解耦。在医学文本里这种能力很关键病历中“考虑结核可能”和“结核杆菌阳性”里的“结核”语义角色完全不同静态向量根本撑不住。具体选型上如果你只有几十万字的科室数据不要从零预训练常见做法是直接用bert-base-chinese12层、768维、12个头当底座再用自己的数据微调。显存不足时换6层蒸馏版F1通常会掉1.5到2个点但训练时间接近砍半属于性价比看得到的取舍。至于要不要换专门的医学预训练权重我的建议是拿两个权重在同一验证集上各跑一次再定别被模型名字唬住。工业场景下实体识别不是表演赛。ALBERT、RoBERTa-wwm这些变体理论上更强但在医学数据量只有几万条时跟bert-base-chinese的差距经常在1个点以内换来的是更多的调参和部署成本。我的原则是能用一个稳定中杯解决的问题不用大杯加料。加载权重时transformers库的from_pretrained第一次运行会从模型托管站拉取参数网络慢是常态。我一般先把参数下载到本地固定路径之后一直用本地路径加载避免每次实验都卡在下载这一步。BERT模型有512个token的长度上限病历动辄上千字送进模型前必须切文本切法直接影响实体边界具体策略在3.2节给代码。2.2 BiLSTM层在BERT表征上再学一层序列特征接BiLSTM不是玄学背后是两类实际需求。第一BERT输出的每个位置向量是全局注意力加权的结果但它没有显式建模“相邻token是否属于同一个实体片段”这种序列结构BiLSTM在时间维上做双向递归等于在BERT表征之上再补一层序列压缩。第二医学实体有很强的复合词形态比如“阿司匹林肠溶片”BiLSTM能学到词内部边界的平滑过渡比直接线性映射更稳。结构参数上我常用单层或双层双向LSTM。hidden_size取256输入是BERT输出的768维双向拼接后变成512维再过线性层映射到标签数。dropout放在0.3上下太大容易欠拟合太小训练集上飘得厉害。显存紧张时把hidden降到128F1掉得不多但参数量减了三分之一以上值得实测对比。LSTM的隐状态初始化我习惯用全零dropout放在输入和层间。PyTorch的LSTM默认对hidden做均匀初始化通常不用额外处理但如果发现loss在前期波动剧烈可以用正交初始化重试。这里有个容易翻车的点BiLSTM对padding位置照样做前向计算如果后续CRF不配合maskpadding位置会产生非法标签。所以CRF必须拿到attention_mask做约束具体实现见第4章的model代码。2.3 CRF层全局最优解码把非法标签序列堵死如果最后一层是softmax加argmax本质是每个token独立选标签。问题在于“B-Drug后面接I-Drug”合理、“B-Drug后面接I-Disease”不合理这种标签依赖softmax公式里根本没有建模模型只能靠样本隐式去学经常学不彻底。CRF在模型输出的发射分数logits之外额外维护一个标签转移矩阵记录任意两个相邻标签之间允许转移的得分。解码时用维特比算法全局搜索一条总得分最高的路径“B-Drug接I-Disease”这种转移如果在训练数据里几乎不出现转移矩阵会给它极低分解码时自然规避。举个例子“阿司匹林哮喘患者夜间加重”。softmax可能把“阿司匹林”标成B-Drug、“哮喘”标成B-Disease看着各自都没错但从实体角度看这应该是“阿司匹林哮喘”整体一个病名。CRF的转移矩阵约束了“B-Drug - I-Disease”路径又约束Disease实体内部不允许O穿插于是解码结果更倾向把“阿司匹林哮喘”作为整体。实体边界的一致性对知识图谱的节点质量影响极大——边界错了图谱里就会出现“阿司匹林哮喘”和“哮喘”两个节点本该是一个或两个关联节点的关系全乱掉。训练时CRF的损失是负对数似然loss -crf(emissions, labels)推理时decode返回全局最优路径。实现我用torchcrf库batch_firstTruemask参数传attention_mask.bool()。训练完可以把crf.transitions打印出来看数值上能直观看到哪些转移是被惩罚的。我见过一次实验里模型给“B-Drug-I-Disease”学了正分原因是标注数据里混入了“药物性肝病”这类复合实体被切成Drug和Disease两个实体模型把错误当成了规律——这种时候先修数据别调CRF。组件核心作用不接它的后果BERT动态上下文表征同词歧义无法区分BiLSTM序列特征压缩实体边界模糊CRF标签转移约束非法序列频出3. 医学语料准备与BIO标注模型上限由数据决定3.1 实体类型定义与BIOES标注实体类型设计是知识图谱的起点。这里我建议最小可用的五类疾病、症状、药物、检查/检验、手术。这五类覆盖了病历里绝大多数高频实体也够下游关系抽取用。类型再细分很容易让标注成本失控比如把“心力衰竭”细分成“慢性心衰”和“急性心衰”模型很容易混淆先做粗粒度比追细粒度更重要。标注方案上BIO最常见但我推荐BIOESE标记实体结尾S标记单字实体。四个标签对边界的表达力远强于两个对“胃肠道出血”这类多字实体BIOES能精确标出结束位置CRF学起来省力得多。标注工作不是一次性活。我一般用Label Studio做两人标注各自标同一批文本然后算一致性。分歧大的类别比如“症状”和“疾病”的界限要在标注规范里补例句。这个前处理阶段看起来费时间但省下的都是后面模型的命。解析CoNLL格式标注文件的代码def load_conll(path): 解析CoNLL格式每行一个token加标签空行分隔句子 samples [] tokens, tags [], [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: if tokens: samples.append((tokens, tags)) tokens, tags [], [] continue token, tag line.split() tokens.append(token) tags.append(tag) if tokens: samples.append((tokens, tags)) return samples逻辑说明按空行切句。医学标注导出时经常每行是“token 空格 标签”但有些工具导出的Tab分隔注意切分符号统一。最后一段如果没有空行结尾也要回存否则数据白丢。标签转ID时五类实体展开成B/I/E/S四类标签加O共21个标签。如果要S标签则每类多一个单独实体标签共26个。label2id的key别手滑写错我习惯在训练前打一条统计日志确认每种标签的样本数不是0。这一步能省很多冤枉时间。3.2 去标识化与滑窗切分医学文本里姓名、病案号、身份证、电话是硬伤。不去标识化的后果是模型可能把“王某”学成一种实体类型上线后遇到真实姓名反而乱标更严重的模型可能学出不该有的隐私关联。我一般用规则把所有数字串和姓名替换成“患者”和“X”占位再做实体标注。去标识化用正则做两轮第一轮替换11位数字串和身份证模式第二轮替换“患者姓名”上下文的常见姓。注意不要把药物剂量里的“500mg”误删剂量是临床信息不是隐私保留它才有价值。BERT的长度上限是512病历经常超长。滑窗切分的代码from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(./weights/bert-base-chinese) def sliding_window(text, max_len384, stride128): 滑窗切分长文本stride让实体有机会完整落在某个窗口 tokens tokenizer.encode(text, add_special_tokensFalse) windows [] start 0 while start len(tokens): end min(start max_len, len(tokens)) windows.append(tokens[start:end]) start max_len - stride return windows逻辑说明stride是相邻窗口重叠的长度。实体跨窗口被截断时重叠部分让同一个实体在下一个窗口里补全。stride太小浪费算力128是个折中值如果文本里长实体特别多加到96。每个窗口的标签按同样的窗口位置从原样本切出来保证token和标签对齐。有一个细节BERT的tokenizer分词结果和原文字符不是严格一一对应的场景比如英文或带词表的模型会把“gastrointestinal”切成subword标签对齐要按token偏移做投影。中文的bert-base-chinese按字切分token和字一一对应标签直接平移即可——这也是中文医学NER项目里最省心的地方。3.3 样本均衡与数据增强医学语料天然不均衡疾病和药物实体数量远多于手术和检查直接拿原始分布训练低频实体的F1经常是0。两个常用解法一是按实体出现频次给样本过采样让低频实体占比提上来二是给loss按标签频率加权重。权重最怕拍脑袋我一般按“总样本数除以该实体样本数”归一化让低频标签权重在1.5倍上下浮动。增强要克制。医学同义词替换有风险“发热”和“发烧”可以互换但“心梗”不能换成“心力衰竭”。如果替换错了模型等于吃到了错误标注。我会构造一个白名单同义词表只允许在同一个上位词集合内替换import random def synonym_augment(tokens, tags, synonym_map, p0.3): 只在B标签位置做同义词替换不破坏实体边界 new_tokens, new_tags tokens[:], tags[:] for i, (tok, tag) in enumerate(zip(tokens, tags)): if tag.startswith(B) and tok in synonym_map: if random.random() p: new_tokens[i] random.choice(synonym_map[tok]) return new_tokens, new_tags逻辑说明只替换B开头的词是因为实体内部其他token不能动否则I/E标签就对不上了。同义词表要人工维护一条词可能对应多个替换项随机取一个。p控制在0.3以内增强过量会把模型教歪。4. 用PyTorch实现BERTBiLSTMCRF可复现的训练流程4.1 加载预训练模型与构造数据集加载权重的代码import torch from transformers import AutoTokenizer, AutoModel model_path ./weights/bert-base-chinese # 提前下载到本地路径可换 tokenizer AutoTokenizer.from_pretrained(model_path) bert AutoModel.from_pretrained(model_path)逻辑说明AutoTokenizer和AutoModel是一组通用接口换其他预训练权重时不用改代码。第一次从模型托管站拉取参数可能很慢建议下载到本地路径后固定复用。model_path里放的是config.json、pytorch_model.bin和vocab.txt三个文件缺一不可。接着是Dataset类把第3章parse出来的samples转成模型输入class NERDataset(torch.utils.data.Dataset): def __init__(self, samples, tokenizer, label2id, max_len384): self.samples samples self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __getitem__(self, idx): tokens, tags self.samples[idx] # 加上BERT的CLS和SEP标记标签同步补O tokens [[CLS]] tokens [[SEP]] tags [O] tags [O] input_ids self.tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) labels [self.label2id.get(t, 0) for t in tags] # padding位置标签填-100CRF用mask过滤掉 pad_len self.max_len - len(input_ids) input_ids [self.tokenizer.pad_token_id] * pad_len attention_mask [0] * pad_len labels [-100] * pad_len return { input_ids: torch.tensor(input_ids), attention_mask: torch.tensor(attention_mask), labels: torch.tensor(labels), } def __len__(self): return len(self.samples)逻辑说明CLS和SEP是BERT的特殊标记标签位补O是因为它们不参与实体标注。padding位置标签填-100配合attention_mask里的0CRF不会计算它们。这里有个常见错误labels用0填充会把padding位置学成O干扰模型——一定要用-100。如果不用包装好的TokenizedDataset自己写collate_fn时要注意labels的padding不能和input_ids混用统一pad到max_len再填-100。4.2 BiLSTMCRF解码层实现模型核心代码from torchcrf import CRF class BertBiLSTMCRF(torch.nn.Module): def __init__(self, bert_model, num_labels, hidden256, num_layers2, dropout0.3): super().__init__() self.bert bert_model self.dropout torch.nn.Dropout(dropout) self.bilstm torch.nn.LSTM( input_sizebert_model.config.hidden_size, hidden_sizehidden, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout if num_layers 1 else 0, ) self.fc torch.nn.Linear(hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): out self.bert(input_idsinput_ids, attention_maskattention_mask) seq out.last_hidden_state # [batch, seq_len, 768] seq self.dropout(seq) lstm_out, _ self.bilstm(seq) # [batch, seq_len, hidden*2] emissions self.fc(lstm_out) # [batch, seq_len, num_labels] if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool()) return loss preds self.crf.decode(emissions, maskattention_mask.bool()) return preds逻辑说明emissions是CRF的发射矩阵每个token对每个标签的打分。CRF的mask必须为bool类型padding位置为False否则loss和decode都会把padding也算进去。这行mask是报错和效果差的高发区——传成float会直接报类型错误传成整型但忘了.bool()也会触发坑。参数说明hidden256双向LSTM输出维度是512两个方向拼接FC层把512映射到num_labels。num_layers2时dropout在层间生效num_layers1时没有层间dropout只能靠前面的Dropout层。CRF的batch_first必须和LSTM保持一致否则维度错位。如果你是想在BERT之上做特征提取而不微调可以把bert的参数requires_grad设为False只训练BiLSTM和CRF这种方案适合线上算力紧张、只更新轻量头的场景。医学NER通常建议微调效果差距明显除非数据量太小怕过拟合。提示mask参数在tokenizer返回时是[1,0,0,...]的整数张量CRF里.decode和loss都需要.bool()。如果你用的是旧版torchcrf需要检查它内部是否支持bool mask不支持的版本换用新版本。4.3 训练循环与评估指标训练循环from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup epochs 10 optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, ) for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) loss model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad() print(fepoch {epoch}, loss {loss.item():.4f})逻辑说明warmup是BERT微调标配前10%的step里学习率从0线性升到2e-5再线性衰减到0。梯度裁剪5.0防止CRF层偶发的大梯度把loss冲爆。这里有个容易被忽略的点scheduler.step()必须放在optimizer.step()之后顺序反了学习率会错位。训练用autocast混合精度显存能省三分之一速度也快但CRF层建议保留float32维特比解码在半精度下容易累积误差。transformers的Trainer默认做了这些处理如果你自己写训练循环要留意。我习惯在第8轮开始每轮看验证F1连续3轮不涨就早停保存验证集F1最高的checkpoint别用最后一个epoch的权重。评估用seqevalfrom seqeval.metrics import classification_report pred_tags, true_tags [], [] model.eval() with torch.no_grad(): for batch in val_loader: preds model(batch[input_ids].to(device), batch[attention_mask].to(device)) for p, l in zip(preds, batch[labels].numpy()): pred_tags.append([id2label[i] for i in p if i ! -100]) true_tags.append([id2label[i] for i in l if i ! -100]) print(classification_report(true_tags, pred_tags))逻辑说明seqeval忽略O标签按实体级别计算F1比token级准确率严格得多。打印结果按实体类型分列哪个类型没学会一眼看出——比如“手术”这一行F1接近0就该回第3章补数据而不是继续调参。训练时长上十万字级别的医学语料10轮在单张V100上大约两小时CPU上跑一轮就要半小时果断切GPU。显存不够时把batch_size降到8并用梯度累积step补足总batch效果差别不大。5. 训练与部署避坑五个最常见的翻车点5.1 现象loss降得很快验证F1却在原地踏步原因attention_mask没传对。最常见的是padding位置参与了CRF计算模型学了一堆padding上的伪标签loss看着降了真实实体边界没学会。解决检查forward里CRF的mask是不是attention_mask.bool()同时labels里padding位置必须填-100。两个条件缺一个都不行。训练日志怎么看正常情况loss从几十降到5以下F1在第二个epoch后开始爬升。如果loss在下降但F1纹丝不动把pred_tags打印前50条看是不是一片O——那是经典的mask缺失症状。5.2 现象“手术”这种低频实体F1一直是0原因样本太少。整个训练集里“手术”实体只出现几十次模型从头到尾没学会它的上下文模式。解决先统计每种实体的span频次远低于其他类型的做过采样。过采样用torch的WeightedRandomSampler按样本里是否含低频实体决定采样概率注意不要只对句子过采样要把含低频实体的全片段保留防止切窗后实体又没了。也可以在loss里给低频标签加权重但别超过1.5-2倍不然模型会把高频实体牺牲掉去迁就低频。5.3 现象长病历里一个实体被拆到两个窗口预测结果中这个实体消失原因滑窗切分时stride太长实体恰好落进窗口接缝。解决调大重叠。stride从128改到64甚至32同时在窗口后处理时合并相邻窗口的预测结果对重叠区域的非O标签保留更长片段的版本优先选择置信度高的。合并窗口时取重叠区域里非O标签的并集如果两个窗口对同一实体给不同类型以较长窗口的预测为准。写个简单的贪心合并函数比再训一个模型省事得多。5.4 现象换环境加载权重报错报错信息指向state_dict不匹配原因transformers版本不一致同一个中文BERT在不同版本生成的key名有差异。比如bert-base-chinese在transformers 4.x里的key是“bert.embeddings.word_embeddings.weight”老版本可能带不同的前缀。解决在requirements.txt里锁定transformers版本比如4.30.2。换环境时先装锁定的版本再跑不要图省事直接装latest。这个坑我踩过两次每次都是版本回退后一切正常。5.5 现象换成医学领域预训练BERT后F1反而降了原因预训练语料和你的科室文本分布不一致。所谓“医学BERT”可能是基于英文论文或公开病历训练的跟你的中文出院小结差距不小。解决两个权重在同一验证集上各跑一轮用数据说话。不要只比整体F1分实体类型比——医学BERT在“疾病”类型上可能更强但在“手术”上可能更弱用分类报告决定去留。模型名字里有“医学”不等于在你的数据上好用别被名字唬住。6. 把实体识别结果做成医学知识图谱从三元组到Neo4j验证实体识别结束后你拿到的是“文本里有哪些实体、什么类型”但知识图谱需要的是“实体之间什么关系”。第3章定义的实体类型就是图谱的本体骨架关系先用规则抽比如“药物-导致-症状”可以从句子里的“引起”“诱发”“出现”这类动词触发没有明确动词的用共现关系兜底同一句同时出现的实体默认建立弱关联。把三元组批量导入Neo4j我常用py2neofrom py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) for ent_type, name in entity_list: # entity_list由NER输出解析 node Node(ent_type, namename) graph.merge(node, ent_type, name) # 按name去重 for drug, symptom in drug_symptom_pairs: a graph.nodes.match(Drug, namedrug).first() b graph.nodes.match(Symptom, namesymptom).first() if a and b: graph.merge(Relationship(a, 导致, b))逻辑说明merge按主键去重不会重复创建节点。实体类型作为节点标签关系类型按临床规则定义。我的验证习惯图谱建好后第一条查询先统计各类型的节点数和最频繁的Drug-Symptom关系对。如果图谱里“手术”类节点数量远小于预期基本能反推NER这一步漏报严重回数据标注环节排查。这个“图谱倒查NER”的闭环是在工业场景下把知识图谱设计落地时最实用的质检手段。做医学实体识别加知识图谱我最大的教训是别急着堆模型先把数据标注和验证闭环搭好。训完后第一件事不是看F1是拿一条真实病历把全链路跑一遍让图谱回答一个临床问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表