简介:面向自然语言处理与医学信息挖掘场景,这份融合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 工具在这类文本上表现不佳的主要原因,不是模型不够深,而是把“序列标注”当成了“分词加分类”的串联任务。BERT+BiLSTM+CRF 的组合解决的是同一个问题:如何在字级别上同时捕捉上下文语义、局部依赖和标签之间的强约束关系,最终把“哪几个字组成一个实体、这个实体属于什么类型、实体之间能否重叠”一次性求解出来。搭建医学知识图谱的前置步骤就是实体识别,这一步的质量直接决定图谱的节点数量和关系质量。适合正在做医疗文本结构化、病历信息抽取、医学文献挖掘,以及需要把非结构化临床文本转化成可查询知识的工程人员。
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_length | 128-256 | 医学文本句子长度分布不均,超过 256 的句子建议截断或滑窗切分 |
model | bert-base-chinese | 中文医学文本的通用起点,若数据量充足可继续预训练 |
output_hidden_states | False | 只取最后一层输出,无需开启全部隐层状态,节省显存 |
2.2 BiLSTM 层:在 BERT 之上的序列上下文建模
BERT 已经建模了全局上下文,为什么还要再接一层 BiLSTM?原因有两个:一是 BERT 的输入是子词(subword),而医学实体识别需要的是字级别的超平面分割,BiLSTM 可以对 BERT 的输出做一次序列压缩和特征筛选;二是 BiLSTM 可以学习到“实体边界”的隐式模式,比如医学文本中频繁出现的“左/右/上/下”方位词后接解剖部位的概率极高。
BiLSTM 的计算流程是:将 BERT 输出的每个 token 向量序列分别输入正向 LSTM 和反向 LSTM,正向学习从句子开头到当前的依赖,反向学习从句子结尾到当前的依赖,然后把两个方向的隐状态拼接。对应到 PyTorch 代码中,nn.LSTM(..., bidirectional=True)的输出维度是hidden_size * 2,这也就是为什么后面接 CRF 层时,线性变换的输入维度要设置为hidden_size * 2。
这里的 LSTM 隐藏层维度一般设为 128-256 之间。太小会丢信息,太大在训练集只有几万条医学文本时容易过拟合。我有一个常用的做法:当 BERT 参数量是 102M 时,BiLSTM 的隐藏层设为 128 即可,此时 CRF 层接收的是 256 维的特征向量,参数量增长不大,但训练速度明显快于直接接全连接层。
2.3 CRF 层:让标签序列合法化
CRF 层解决的核心问题是“标签之间的转移约束”。BIO 标注体系下,O -> I-疾病是非法转移(没有 B 直接进入 I),B-疾病 -> 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 的常见数据类型有入院记录、出院小结、影像报告、病理报告。推荐使用 BIO(Begin, Inside, Outside)标注体系,实体类型建议控制在 5-8 类以内,过多会让 CRF 层学到不可靠的转移关系。我常用的一套医学实体类型如下:
| 实体类型 | 示例 | BIO 标签 |
|---|---|---|
| 疾病 | 非小细胞肺癌 | B-疾病, I-疾病, I-疾病, I-疾病, I-疾病 |
| 症状 | 刺激性干咳 | B-症状, I-症状, I-症状, I-症状 |
| 药物 | 吉非替尼 | B-药物, I-药物, I-药物, I-药物 |
| 检查 | 胸部增强CT | B-检查, 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 只能打一个标签。这是 BERT+BiLSTM+CRF 方案的已知局限。解决方法是:要么在预处理阶段将嵌套实体拆开,用两个独立序列标注任务层叠训练;要么在损失函数中加入边界回归,形成实体边界和实体类型的多任务结构。工程初期建议直接拆开,先保证每个实体是平铺结构。
第三,大小写和全半角。英文药物名的写法不稳定,比如“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, truncation=True, max_length=max_len, padding="max_length", return_offsets_mapping=True) 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_mapping=True返回的是每个 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_hidden=128): super().__init__() self.bert = BertModel.from_pretrained(bert_name) self.lstm = nn.LSTM(input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True) self.dropout = nn.Dropout(0.3) self.fc = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags=num_tags) # 前文定义的 CRF 类 def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_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 的 attention;CRF 用它跳过填充位置的转移计算。
3.4 训练配置和四个关键超参数
训练医学 NER 模型时,学习率和 warmup 比例最容易出错。BERT 微调的典型学习率是2e-5到5e-5,但 BiLSTM 和 CRF 层建议使用稍大的学习率,因为它们的参数没有经过预训练,初始状态和 BERT 不在同一个量纲上。可以设置分组学习率:BERT 层2e-5,BiLSTM+全连接+CRF 层1e-3。
| 超参数 | 建议值 | 影响 |
|---|---|---|
learning_rate | 2e-5 (BERT) / 1e-3 (下游) | 过大会导致预训练表示被破坏 |
warmup_ratio | 0.1 | 前 10% 的 step 逐渐升温,稳定微调 |
batch_size | 8-16 | 显存允许时尽量大,稳定 BN 的统计量 |
max_epochs | 5-8 | 医学数据小、噪声高,超过 10 轮容易过拟合 |
优化器推荐用 AdamW,weight_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, truncation=True, max_length=128) 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})", name=entity, type=type ) 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) """, subj=subj, obj=obj )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 的错误概率。对比实验的收益可以这样验证:对同一份测试集,分别跑纯 BERT+CRF 和 BERT+BiLSTM+CRF,逐条记录 F1 差异,观察 BiLSTM 在哪些实体类型上贡献最大。这个差异分布,将直接决定是否会选择 BERT+CRF 的轻量方案,还是坚持完整的三层架构。
本文还有配套的精品资源,点击获取