ARTICLE DETAIL

资讯详情

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

BERT+BiLSTM+CRF与知识图谱融合:医疗医生推荐系统的实体抽取实战

BERT+BiLSTM+CRF与知识图谱融合:医疗医生推荐系统的实体抽取实战 简介基于BERT、CRF和BiLSTM的医生推荐系统是一套面向高校计算机专业毕业设计场景的完整项目资源。项目经过调试可以直接运行适合学生用于毕业设计、课程设计或期末大作业也适合希望掌握深度学习与知识图谱落地方法的开发者。资源包含完整Python源码、项目说明文档和可用于训练的医疗数据集覆盖数据处理、知识图谱构建、医疗实体识别、医生推荐等关键流程。压缩包共一百一十四个文件以三十七个Python脚本为主体同时包括爬虫配置、前端页面、配置说明、CSV疾病数据以及模型文件等整体约四十点四二兆目录结构便于按功能模块学习与扩展。目前已有二百四十五人学习下载。通过该项目使用者可以复现一套完整的医生推荐系统掌握从数据处理到模型部署的排错方法并能基于疾病数据开展进一步实验。1. 医生推荐系统为什么先用 BERTBiLSTMCRF 做实体抽取一个患者输入「右边肋骨下面疼了三天偶尔恶心」你指望推荐系统直接给出消化内科还是肝胆外科的医生。常见的初版做法是维护一个症状关键词词典命中「恶心」就推消化科。但真实文本里到处是同义改写、口语化表达和复合症状词典规则在门诊场景的召回率往往只有六成出头还把「肋骨下方」这种位置信息当成噪音丢掉。把 BERT 预训练语义、BiLSTM 上下文特征、CRF 标签约束串成同一套序列标注管线先把主诉里的症状、疾病、部位、时间、科室抽成结构化实体再交给知识图谱去查科室和医生这比端到端硬训一个推荐网络更可控也更容易解释。这套路线需要处理的中文分词、长文本截断、标注一致性和图查询召回问题恰好都是推荐系统的瓶颈所在。适合手里有一份医疗文本标注数据想把模型和图谱真正接进推荐链路的工程师。2. BERTBiLSTMCRF 三层结构拆解从字符向量到标签约束2.1 先想清楚预测目标BIOES 标签体系与实体边界序列标注的任务是为输入文本的每个字分配一个标签。推荐场景里我们关心五类实体症状、疾病、部位、检查、科室外加一个「无关」类别。标签体系我推荐 BIOES 而不是传统 BIO因为 BBegin和 EEnd能明确每个实体的单字与结尾位置在医疗文本里「上腹部疼痛」「转移性右下腹痛」这类长实体居多BIOES 的边界信息对 CRF 层学习实体转移更有帮助。标签集合展开后是 5 类实体 * 3 种位置B/I/E加上 S 与 O共 21 个标签。模型输出维度就是 21不是 5。2.2 BERT 编码层与字向量对齐shape 从 (batch, seq) 到 (batch, seq, hidden)BERT 在这一层做的是把每个字编码成语义向量。输入是 tokenizer 处理后的 input_ids 和 attention_mask输出维度是 (batch, seq_len, hidden_size)中文医疗场景用 base 模型hidden_size 是 768。这里容易踩的第一个坑是 token 粒度BERT 自带 WordPiece 词表英文按 subword 切分中文则按单字切分和标注数据「一字一标签」的粒度天然对齐不需要额外做分词。第二个坑是 [CLS] 和 [SEP] 两个特殊 token 占据了序列首尾送入 BiLSTM 之前要把它们的向量位裁掉否则标签对齐就错位了。2.3 BiLSTM 输出与 CRF 转移矩阵为什么不能缺 CRFBiLSTM 负责从 BERT 输出的上下文字向量里再提一层局部时序特征输出维度是 (batch, seq_len, hidden*2)再过一层线性层映射成 21 个标签的发射分数。难点在于标签之间的空间约束比如「O」后面直接跟「E」是非法的「B-症状」后面跟「I-疾病」也不合常理这类约束 BiLSTM 学不好因为发射分数是逐位置独立计算的。CRF 层引入一个 21x21 的转移矩阵为标签转移打分训练时用前向算法算整个序列分数的 log-sum-exp推理时用维特比解码找出全局最优标签链。对推荐系统而言CRF 防止的是「症状实体只标出一半」这种结构性错误比单纯拉高 F1 更有实际意义因为实体残缺会让知识图谱查询直接落空。2.3.1 转移矩阵具体限制哪些路径转移矩阵里学习出来的值本质是标签转移的代价。训练数据里「B-症状」后面出现过很多次「I-症状」「B-症状」后面几乎从不出「E-疾病」矩阵自然学出合理转移大于非法转移。你也可以手动把非法转移置为 -10000比如所有标签到 [CLS] 的位置以及「O」到「E-部位」这是数据量小时加速收敛的可选手段。2.4 推理阶段用维特比解码而不是逐标签取 argmax推理时逐标签取最大分数会得到边界不连贯的序列比如「右|腹|部|疼|痛」被标成「O|B-症状|I-部位|I-症状|E-症状」这会让实体抽取结果无法与图谱对齐。正确做法是把 BiLSTM 的发射分数和 CRF 转移矩阵一起交给维特比算法动态规划回溯出的路径才是全局最优。要不要用维特比的另一个判断标准是业务容忍度医生推荐场景把症状错标成疾病查询出的科室完全不同这种错误比「实体短一个字」严重得多因此必须做全局解码。3. 用 PyTorch 把 BERTBiLSTMCRF 跑起来训练与推理代码3.1 环境准备与依赖选择主线用 PyTorch 配合 HuggingFace Transformers。数据集小、硬件有限的场景下BERT 部分固定 checkpoint 微调只有 BiLSTM 和 CRF 层参与梯度更新单张 1080Ti 就能跑完三四个 epoch。pip install torch transformers datasets seqeval说明seqeval 用于实体级别的精确率、召回率与 F1 计算比逐字准确率更有参考价值。Transformers 负责下载bert-base-chinesecheckpoint如果你在内网环境可以先手动下载到本地再用from_pretrained指向目录。3.2 数据集标签映射与 Batch 构造标注数据最常用的落地格式是每行一个「字 空白 标签」句子之间用空行分隔。读取时要同时产出四样东西字序列、标签 id、BERT 的 input_ids、attention_mask。def load_dataset(path, tag2id): sentences, labels [], [] sent_tokens, sent_tags [], [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: if sent_tokens: sentences.append(sent_tokens) labels.append(sent_tags) sent_tokens, sent_tags [], [] continue char, tag line.split() sent_tokens.append(char) sent_tags.append(tag2id[tag]) return sentences, labels标签映射字典要提前统计训练集里所有出现的标签测试集出现新标签说明标注流程有遗漏这类错误比模型效果差更值得警惕因为最终的图谱查询会直接跳过未识别实体。3.3 模型定义BERTEncoder BiLSTM CRF模型核心是组合三块BERT 基座、双向 LSTM、手写 CRF 层。手写 CRF 的参数量很小核心是转移矩阵和维特比解码官方 torch 版本没有内置 CRF独立实现反而更好排查。class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, tag_size, hidden_dim256): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.bilstm nn.LSTM(768, hidden_dim // 2, num_layers2, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_dim, tag_size) self.transitions nn.Parameter(torch.randn(tag_size, tag_size)) self.tag_size tag_size self.start_idx tag_size - 2 self.end_idx tag_size - 1 def forward_sequence(self, input_ids, mask): outputs self.bert(input_ids, attention_maskmask) seq_emb outputs.last_hidden_state seq_len mask.sum(dim1) packed pack_padded_sequence(seq_emb, seq_len.cpu(), batch_firstTrue, enforce_sortedFalse) lstm_out, _ self.bilstm(packed) lstm_out, _ pad_packed_sequence(lstm_out, batch_firstTrue) return self.fc(lstm_out)pack_padded_sequence的作用是把 batch 里的短序列从 BERT 输出中跳过无效 padding避免 BiLSTM 把 [PAD] 位置也编码进上下文。[CLS] 和 [SEP] 的掩码位置会影响 mask.sum 计算所以前处理时需要把两个特殊 token 的 mask 置为 1、对应标签置为 O否则还原标签序列时长度对不上。3.3.1 手写 CRF 的前向分数计算CRF 的训练目标是最大化正确标签路径的分数同时归一化所有路径的 log-sum-exp。代码实现如下def forward_loss(self, emissions, tags, mask): batch_size, seq_len tags.shape score torch.full((batch_size, self.tag_size), -10000.0, deviceemissions.device) score[:, self.start_idx] 0.0 for t in range(seq_len): mask_t mask[:, t].unsqueeze(1) emit_t emissions[:, t, :] transition_scores score.unsqueeze(2) self.transitions.unsqueeze(0) logsumexp torch.logsumexp(transition_scores, dim1) score mask_t * (logsumexp emit_t) (1 - mask_t) * score score score self.transitions[:, self.end_idx].unsqueeze(0) total_score torch.logsumexp(score, dim1) gold_score self.gold_score(emissions, tags, mask) return (total_score - gold_score).mean()scores的 shape 是 (batch, tag)每一轮迭代把上一轮所有标签的分数加上转移分数再做 logsumexp等效于累积前向路径概率。注意 mask_t 用乘保留非 padding 段的计算结果padding 段新旧分数不变。gold score 计算如下def gold_score(self, emissions, tags, mask): batch_size, seq_len tags.shape score torch.zeros(batch_size, deviceemissions.device) score self.transitions[self.start_idx, tags[:, 0]] for t in range(1, seq_len): score self.transitions[tags[:, t - 1], tags[:, t]] * mask[:, t] last_tags tags.gather(1, mask.sum(dim1).unsqueeze(1) - 1).squeeze(1) score self.transitions[last_tags, self.end_idx] for t in range(seq_len): score emissions[torch.arange(batch_size), t, tags[:, t]] * mask[:, t] return scoregold_score是正确标签链路径上的累计打分从 start 转移到第一个标签、逐标签转移、最后从最后一个真实标签到 end 并加上对应位置的发射分数。训练 loss 是 total_score 与 gold_score 的差值直观理解最小化「所有路径的对数分数和」与「正确路径分数」之间的差等价于 CRF 论文里的负对数似然。3.4 训练循环与维特比解码训练循环本身没有特殊情况注意把 CRF 层的参数和 BERT 的解码层参数放进同一个优化器。冻结 BERT 的做法是for p in model.bert.parameters(): p.requires_grad False先跑通再逐步解冻能显著降低显存占用。def decode(self, emissions, mask): batch_size, seq_len emissions.shape[:2] backpointers [] score torch.full((batch_size, self.tag_size), -10000.0, deviceemissions.device) score[:, self.start_idx] 0.0 for t in range(seq_len): transition_scores score.unsqueeze(2) self.transitions.unsqueeze(0) best_scores, best_tags transition_scores.max(dim1) score best_scores emissions[:, t, :] backpointers.append(best_tags) best_last (score self.transitions[:, self.end_idx].unsqueeze(0)).argmax(dim1) best_tags_list [best_last] for t in range(seq_len - 1, 0, -1): best_last backpointers[t].gather(1, best_last.unsqueeze(1)).squeeze(1) best_tags_list.append(best_last) return torch.stack(best_tags_list[::-1], dim1)decode 里维护一个 backpointers 列表记录每一步的最优前驱标签回溯时从最后一个有效位置往前串。训练时不要用 decode 的结果做梯度回传decode 只是推理函数。3.5 预测时保持 BERT 截断正确的三个要点第一BERT 最大序列长度默认 512医疗主诉很少超过 256但历史病历诊断写得很长超过 max_len 必须做滑窗切分。第二切分时不能让实体跨窗口简单做法是先按 200 字切再检查窗口首尾是不是中文字符如果是字组中间回退到上一个标点。第三一个窗口实体在窗口边界被切掉是推荐链路空结果的主要原因我习惯把 max_len 设为 128 而不是 512虽然窗口变小但模型对短主诉的标注更稳定图查询在长文本场景下也能接受多段截断。输出结果后将所有窗口的标签回填到原始位置回填时遇到同类型实体相邻片段做合并。4. 从实体到知识图谱Neo4j 建模与医生推荐链路4.1 实体关系设计疾病、科室、医生边怎么建实体抽取完成后要进图存储。图结构决定推荐查询的召回范围我常用的医疗场景实体与关系如下实体类型含义关键属性Disease疾病name, icd_codeSymptom症状name, descriptionDepartment科室name, hospital_levelDoctor医生name, title, rating_outpatientHospital医院name, level关系起点终点含义HAS_SYMPTOMDiseaseSymptom疾病包含哪些主诉LOCATED_INSymptomBodyPart症状发生的部位TRIAGE_TODiseaseDepartment疾病应该挂哪个科WORKS_INDoctorDepartment医生执业科室TREATSDoctorDisease医生擅长疾病边的方向要从查询出发设计。推荐链路的起点是 patient 文本抽出的 Symptom 和 BodyPart终点是 Doctor所以真实查询是从 Symptom 先到 Disease 再到 Department 再到 Doctor。把 Simple 的直接连接也建出来可以减少查询跳数我通常会冗余一条SYMPTOM_TRIAGE_TO边图和关系型表不一样冗余边是常态。4.2 py2neo 批量写入与去重策略用 py2neo 逐条写入会很慢10 万级别实体建议用merge批量提交from py2neo import Graph, Node, Relationship, Subgraph graph Graph(bolt://localhost:7687, auth(neo4j, password)) def upsert_doctor(name, department, title, rating): doctor Node(Doctor, namename, titletitle, rating_outpatientfloat(rating)) dep_node graph.nodes.match(Department, namedepartment).first() if not dep_node: dep_node Node(Department, namedepartment) graph.merge(doctor, Doctor, name) graph.merge(dep_node, Department, name) rel Relationship(doctor, WORKS_IN, dep_node) graph.merge(rel, WORKS_IN, name)merge按主键去重不存在的节点和关系才创建。重复导入同一份 JSON 不会产生重复节点但关系去重要指定关系主键否则 py2neo 会按默认身份字段判断容易出现多条重复关系。医生姓名同名问题在医疗数据里常见name做不了唯一主键时建议拼接医院字段形成namehospital作为主键字段。4.3 推荐候选的查询从图里找出科室与医生主诉经 NER 抽出「患病部位 症状」后第一步是实体归一化把「腹上部」归一成「上腹部」Disease节点上维护 alias 属性归一化采用最短编辑距离做模糊匹配。做完归一化就执行 Cypher 查询MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE s.name IN $symptom_names WITH d, count(s) AS matched_symptom_count ORDER BY matched_symptom_count DESC LIMIT 3 MATCH (d)-[:TRIAGE_TO]-(dep:Department)-[:WORKS_IN]-(doc:Doctor) RETURN dep.name AS department, doc.name AS doctor, doc.title AS title, doc.rating_outpatient AS rating ORDER BY matched_symptom_count DESC, rating DESC LIMIT 10matched_symptom_count用来度量疾病和患者症状的重合度取前 3 个疾病再展开医生。如果只用名称直接匹配Department或者用 BERT 的相似度计算工具虽然也能落地但没了图谱的多跳能力患者问「右腹疼两天且晨起加重」时无法从部位节点转到可能的器官再关联疾病我一般会先走Symptom-LOCATED_IN-BodyPart把同区段疾病候选扩出来再走Disease-TRIAGE_TO-Department过滤科室相当于加了一层图谱侧召回。4.4 排序与可解释一键生成推荐理由图谱查询返回的是候选集排序阶段把三种信号合并起来NER 文本实体的置信度、症状到疾病的重合度、医生静态评分如职称与门诊好评率。条件排序结果展示时要给推荐理由这是推荐系统是否被采信的关键推荐医生张某某主任医师 推荐科室消化内科 依据主诉「右上腹持续疼痛两天」匹配胆囊炎表现患者未提供发热黄痘 症状需进一步腹部 B 超鉴别。理由模板可以直接从图里取数据生成比如用matched_symptom_count回溯匹配到的症状名称填进模板避免推荐结果不可解释。拿到科室后还要做二次确认门诊资源是强约束同一科室下再按医生的职称、号源余量排序这是知识图谱与纯算法排序差异最大的地方。5. 数据集、指标与效果调优标注规范和最容易翻车的三个地方5.1 数据格式与标注一致性数据集推荐系统里最容易被忽视的是标注一致性。我会在数据目录下固定三类文件train.txt、dev.txt、test.txt每行「字 BIOES 标签」句子间空行。统计诊断性文本与主诉文本时标签重点要注意同一实体在不同句子里的边界是否一致比如「慢性乙型病毒性肝炎」在一条数据里拆成慢性 乙型病毒性肝炎另一条却标成整个疾病这就是标签不一致。做实体层面的一致率抽检用两个标注员的重叠数据计算Kappa 系数低于 0.8 就直接返工模型最终 F1 永远赶不上人工标注的分歧上限。5.2 指标用严格实体匹配分类任务看逐标签 accuracy序列标注这里要按实体级别严格匹配实体的边界和类型都完全一致才算对。seqeval库直接输出分类报告seqeval按实体计算 precision、recall、F1还能细分到 Symptom、Disease、Department 每个类别。python eval.py --model_path ./checkpoints/last.pt --test_path ./data/test.txt关注点不是整体的 F1而是「部位」和「症状」这两个类别的 F1。因为部位实体短、出现频率高标签容易被 O 吞掉症状实体长、边界容易合并相邻描述。如果 Department 类别的 F1 低于 80%推荐结果基本不可用因为部门实体是图查询的必过节点。5.3 样例小数据集 F1 上不去先找 Label Bias在 3000 句的小数据集上最大的坑是 O 标签占比过高。医疗文本大量字符是「、」「的」「近三天」之类修饰词O 在标签分布中占 70% 以上模型容易把所有内容判为 O。处理办法有两个多轮训练时对非 O 标签的 loss 乘以 2 的权重或者使用ignore_index过滤 O 位置。CRF 对非 O 的约束更强因为转移矩阵对所有标签一视同仁把 O 的发射分数调高反而会让实体召回更差。另一个容易踩的是 BERT 微调步数太少冻结 BERT 直接训 BiLSTMCRF 整体收敛更快。5.4 推荐链路里过滤坏实体置信度阈值与规则模型输出的实体不能全信尤其是长尾部样本。我在推荐链路里加了两个保险。一是解码时把维特比路径的分数转成逐标签概率再把实体所有位置的概率取几何平均低于阈值比如 0.85 的实体全部丢弃。阈值用开发集调越高越保守。二是规则过滤比如实体长度小于 2 的「疼」、多个轴词像「右腹部」拆出「腹部」导致重复实体直接按白名单与黑名单组合。黑名单只放停用词「腹部」「腰部」这类部位词不能进黑名单否则链接BodyPart的边会直接断掉。最后一个技巧上线后的模型不能只靠测试集验收。推荐系统在每日真实问询里会有大量未登录词和超长实体线上日志回传之后做一轮「无推荐」与「低置信度实体」统计生成 top 500 的新样本每两周用它增量训练一次。增量样本不需要等排期标注 200 条后直接在原 checkpoint 上调 1 个 epoch实体的 F1 在知识图谱查询边界上的改善往往比重新调一遍 BERT 超参更明显。本文还有配套的精品资源点击获取
返回列表