简介:面向医疗信息化与自然语言处理学习者,这套基于Python和PyTorch实现的中文电子病历命名实体识别项目,聚焦从非结构化病历文本中自动抽取医疗实体。资源共含2000个文件,主体为1994个txt病历文本数据及预处理中间结果,另配5个Python脚本实现主程序、模型定义、数据管理、工具函数与格式转换,1个Markdown文档提供使用说明,整体压缩包仅11.22MB,结构清晰且轻量易用。已有114人学习/下载。通过学习可掌握中文NER完整流程,包括文本分词与预处理、BIO/BIOES序列标注、实体字典构建、PyTorch模型搭建训练,以及准确率、召回率、F1值等评估方法。项目代码模块化程度高,便于替换数据集和调整网络结构,适合NLP初学者、医疗文本挖掘人员和毕业设计参考,可直接运行体验从原始病历到实体识别输出的全过程。
1. 为什么中文电子病历需要单独做一版命名实体识别
把一份出院小结里的症状、用药、检查结果从自由文本里抽出来,这个活儿在 NLP 里叫命名实体识别。到了中文电子病历场景,这套东西和通用领域的实体抽取完全是两个难度:术语高度密集、缩写和口语化表达多、实体边界模棱两可。基于 Python + PyTorch 实现中文电子病历命名实体识别,本质上是拿 BERT 做语义表征、BiLSTM 做序列编码、CRF 做标签约束,把三层结构串成一条完整流水线,解决病历结构化、研究队列筛选、质控字段抽取这类落地问题。
这套方案适合两类人。一类是熟悉 Python 但对医学文本不太熟的工程师,想用成熟框架快速出一个可用模型;另一类是已经在用规则做病历抽取、想换成深度学习方案又担心可控性的算法组。本文会把从数据标注、模型搭建到训练评估、部署推理的每一步讲清楚,参数给到可直接抄作业的程度,再把最容易翻车的几个位置单独拎出来说透。
2. 方案选型与数据准备:先明确实体类型,再看 BIO 标注怎么落
为什么中文电子病历 NER 的标准方案是 BERT + BiLSTM + CRF,而不是单纯的 BERT 微调?核心原因是医疗标注数据太少且代价高。BERT 预训练模型提供了通用的语言先验,但医学语境下的局部语义(“腹痛”“反跳痛”“向心性肥大”这一类短语)需要在少量训练数据上快速适配,BiLSTM 能抓住这种短距离上下文。CRF 则是最后一道保险,它能把“B 标签后面不能直接跟另一个实体的 I 标签”这类硬约束直接编进模型结构里,避免输出完全不合法的标签序列。少了任何一层,模型在业务数据上的表现都会有可感知的下降。
2.1 中文电子病历的实体类型:症状、药品、检查项目怎么定边界
通用命名实体识别关注人名、地名、机构名,中文电子病历关注的是症状、体征、诊断、检查、检验、药物、手术这些医学概念。实体类型定义直接决定标注成本和模型上限,项目启动前一定要和临床科室把边界对齐。以“腹痛伴有恶心呕吐”为例,如果只定义“症状”一个大类,模型会把整句话合成一个实体;如果拆成“腹痛”“恶心”“呕吐”三个实体,后续做症状分布统计时会更细,但标注一致性的难度也翻倍。
我一般建议第一版实体类型控制在 5 到 8 个,优先覆盖下游结构化需求最迫切的几类。类型定义得越细,训练数据的类别分布越不均匀,需要标注的样本量也越大。实体类型需要在标注规范里给定义并附典型例子,比如“症状/体征”指患者主观感受和客观体征,“药物”包含药品通用名、商品名和剂量剂型统一归为一类处理。这样做的好处是标注员有据可依,模型学到的边界也更稳定。
2.2 标注规范:BIO 标记法为什么够用,BIOES 什么时候用
拿到原始病历文本后要先转换成序列标注格式。最常用的标记法是 BIO:B 表示实体开始,I 表示实体内部,O 表示非实体。每个标签都带上实体类型后缀,形成 B-symptom、I-symptom、B-drug、I-drug 这样的标签集合。标注文件里每个字符占一行,字符和标签用空格分隔,空行表示一句话结束。下面是一个最小示例:
| 字符 | 标签 |
|---|---|
| 胃 | B-symptom |
| 部 | I-symptom |
| 疼 | I-symptom |
| 痛 | I-symptom |
| 两 | O |
| 天 | O |
BIOES 在 BIO 基础上增加了 E(End)和 S(Single),对单字实体更友好,但标签数更多、标注规则更复杂。在电子病历场景里,实体普遍跨两到五个字,BIO 足以支撑边界学习,且标注一致性更好,所以我更推荐用 BIO。如果你的标注数据里存在大量单字实体,比如“热”单独代表发热症状,那可以切到 BIOES,模型对单字实体的召回通常能涨几个点。
2.3 环境与依赖:Anaconda 建环境、PyTorch 和 CUDA 版本对齐
环境层面我习惯用 Anaconda 单独开一个 Python 3.9 环境,PyTorch 安装版本和机器 CUDA 版本要对齐。没有独立显卡或者显存只有几 G 的机器,建议装 CPU 版先把小批量流程跑通,再换 GPU 机器训练,不要在 CUDA 版本不匹配的情况下硬撑。依赖方面核心是 transformers、torch、seqeval,其他都是常规配套。
conda create -n ehr_ner python=3.9 conda activate ehr_ner pip install torch transformers seqeval如果机器有 N 卡,先查显卡驱动支持的 CUDA 版本,再安装对应 PyTorch。版本对不上时常见症状是torch.cuda.is_available()返回 False,此时训练不会报错,但会龟速运行,等发现时往往浪费了一整天。这个步骤值得花十分钟严格确认,返工成本远高于等待下载的成本。
2.4 数据读取与标签映射:用这个函数直接加载 BIO 文件
标注文件读进来后要转换成 Python 结构。下面的函数把 BIO 文件逐行解析成句子列表和标签列表,一个句子对应一份 list。
def load_bio_data(file_path): """读取BIO标注文件。 文件格式:每行一个字符和一个标签,用空格分隔;空行表示一句话结束。 """ sentences, labels = [], [] cur_chars, cur_tags = [], [] with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: if cur_chars: sentences.append(cur_chars) labels.append(cur_tags) cur_chars, cur_tags = [], [] continue char, tag = line.split() cur_chars.append(char) cur_tags.append(tag) if cur_chars: sentences.append(cur_chars) labels.append(cur_tags) return sentences, labels这个函数的核心逻辑是按空行切句。标注文件如果是从标注平台导出的,格式可能带\t分隔符,把split()改成split("\t")即可。读进来后建立label2id和id2label两个字典,标签和数字之间的映射要保证后续训练和推理共用同一份,否则模型输出对不上原来的实体类型。另一个容易出错的地方:文件末尾的换行符和空行处理不当会丢掉最后一句,读取后打印一下句子数量和标签数量,和源文件核对。
3. 用 PyTorch 搭建 NER 模型:从 BERT 分词到维特比解码的完整实现
模型部分分为 Dataset 构造、模型结构定义、CRF 层实现、训练循环四块。每一步都有个容易踩的细节,我会把关键代码展开并说明为什么这样写。
3.1 Dataset 构造:逐字切分、标签对齐、填充用 -100
电子病历文本进入 BERT 之前要转成 input_ids,这个过程容易出标签错位。常见错误是直接用tokenizer(list(text))做批量编码,因为 BERT 的 WordPiece 可能把一个词拆成多个 token,拆完后 token 数量比原始字符多,标签没跟着扩展就错位了。稳妥做法是逐字循环,把每个字符 tokenize 后的子 token 数量记下来,标签按同样次数重复。
from torch.utils.data import Dataset class EHRDataset(Dataset): def __init__(self, sentences, labels, tokenizer, label2id, max_len=128): self.sentences = sentences self.labels = labels self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): chars = self.sentences[idx] tags = self.labels[idx] tokens = [] # 切分后的token序列 label_ids = [] # 与token一一对应的标签id for char, tag in zip(chars, tags): sub_tokens = self.tokenizer.tokenize(char) if not sub_tokens: sub_tokens = [self.tokenizer.unk_token] tokens.extend(sub_tokens) label_ids.extend([self.label2id[tag]] * len(sub_tokens)) # 截断并加上CLS和SEP tokens = [self.tokenizer.cls_token] + tokens[: self.max_len - 2] + [self.tokenizer.sep_token] label_ids = [-100] + label_ids[: self.max_len - 2] + [-100] attention_mask = [1] * len(tokens) # 填充到max_len,pad位置的标签用-100 padding_len = self.max_len - len(tokens) tokens += [self.tokenizer.pad_token] * padding_len label_ids += [-100] * padding_len attention_mask += [0] * padding_len return { "input_ids": torch.tensor(self.tokenizer.convert_tokens_to_ids(tokens)), "attention_mask": torch.tensor(attention_mask), "labels": torch.tensor(label_ids), }这段代码有两个细节值得注意。第一,标签扩展用len(sub_tokens)计算,中文单字被拆成多段的情况很少,但碰到英文缩写、数字、特殊符号时能规避整体平移。第二,padding 位置的标签设成 -100 而不是某个真实标签的 id,因为 CRF 计算 loss 时要忽略这些位置,沿用 -100 的惯例可以让后续处理更统一。max_len=128是初始值,病历句子普遍偏长时改成 256,但显存占用会明显上升,需要和 batch_size 一起权衡。
3.2 模型结构:BERT 输出接 BiLSTM 再接全连接层
模型主体是一个自定义的nn.Module,BERT 负责把每个 token 编码成高维向量,BiLSTM 在前向和后向两个方向继续提炼上下文特征,最后接线性层映射到标签数量上。
class NERModel(nn.Module): def __init__(self, bert_dir, num_labels, lstm_hidden=128, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_dir) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=1, batch_first=True, bidirectional=True, ) self.dropout = nn.Dropout(dropout) # 双向LSTM的拼接维度是2 * lstm_hidden self.fc = nn.Linear(lstm_hidden * 2, num_labels) self.crf = CRF(num_labels) def forward(self, input_ids, attention_mask): # 取BERT最后一层输出,形状为[batch, seq_len, hidden_size] sequence_output = self.bert( input_ids=input_ids, attention_mask=attention_mask ).last_hidden_state lstm_out, _ = self.bilstm(sequence_output) lstm_out = self.dropout(lstm_out) logits = self.fc(lstm_out) return logitsbert_dir填bert-base-chinese或本地模型目录路径,第一次运行会自动下载权重。lstm_hidden我常用 128,对中文电子病历通常够用;数据量大且实体类型超过 8 类时再提到 256。这个超参数对显存影响不大,但对效果有影响,值得记录对比。dropout 主要作用在 LSTM 输出上,0.1 在多数场景下是安全值,数据量小时可以降到 0.05。
3.3 CRF 层:转移矩阵和前向算法,自己实现比用第三方库省心
CRF 层是这套方案里最值得亲手实现的部分。第三方库torchcrf在部分环境存在编译问题,并且自定义 CRF 能让转移矩阵的约束逻辑完全透明,调试和扩展都方便。下面是可直接使用的简化版线性链 CRF:
class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags = num_tags # trans[i][j] 表示从标签i转移到标签j的得分 self.trans = nn.Parameter(torch.randn(num_tags, num_tags) / num_tags) self.start_trans = nn.Parameter(torch.randn(num_tags) / num_tags) self.end_trans = nn.Parameter(torch.randn(num_tags) / num_tags) def _forward_score(self, emissions, mask): """所有可能标签序列的对数分子,用前向算法累计。""" batch, seq_len = mask.shape score = self.start_trans.unsqueeze(0) + emissions[:, 0] for t in range(1, seq_len): next_score = score.unsqueeze(2) + self.trans.unsqueeze(0) next_score = torch.logsumexp(next_score, dim=1) next_score = next_score + emissions[:, t] score = torch.where(mask[:, t].unsqueeze(1) == 1, next_score, score) score = score + self.end_trans.unsqueeze(0) return torch.logsumexp(score, dim=1) def _gold_score(self, emissions, tags, mask): """给定真实标签序列的得分。""" batch, seq_len = mask.shape idx = torch.arange(batch, device=emissions.device) score = self.start_trans[tags[:, 0]] + emissions[idx, 0, tags[:, 0]] for t in range(1, seq_len): score = score + self.trans[tags[:, t - 1], tags[:, t]] * mask[:, t] score = score + emissions[idx, t, tags[:, t]] * mask[:, t] last_pos = mask.sum(dim=1).long() - 1 score = score + self.end_trans[tags[idx, last_pos]] * mask[:, -1] return score def forward(self, emissions, mask, tags=None): if tags is None: return self.decode(emissions, mask) loss = self._forward_score(emissions, mask) - self._gold_score(emissions, tags, mask) return loss.mean() def decode(self, emissions, mask): """维特比解码,输出每个batch的最优标签序列。""" batch, seq_len = mask.shape backtrace = [] score = self.start_trans.unsqueeze(0) + emissions[:, 0] for t in range(1, seq_len): next_score = score.unsqueeze(2) + self.trans.unsqueeze(0) best_score, best_tag = next_score.max(dim=1) best_score = best_score + emissions[:, t] score = torch.where(mask[:, t].unsqueeze(1) == 1, best_score, score) backtrace.append(best_tag) end_score = score + self.end_trans.unsqueeze(0) best_last = end_score.argmax(dim=1) best_path = [best_last] for b_tag in reversed(backtrace): prev = b_tag.gather(1, best_path[-1].unsqueeze(1)).squeeze(1) best_path.append(prev) best_path.reverse() lengths = mask.sum(dim=1).tolist() return [path[:length].tolist() for path, length in zip(zip(*best_path), lengths)]_forward_score计算所有可能标签序列的归一化因子,_gold_score计算真实标签序列的得分,两者相减得到负对数似然。decode里用维特比算法做全局最优解码,兼顾转移约束和发射分数。维度上score.unsqueeze(2)把上一时刻得分扩展成 [batch, num_tags, 1],与转移矩阵相加后沿 dim=1 做 logsumexp,得到当前时刻每个标签的累计得分。这些维度操作写一次容易错,建议在小规模数据上用logits.shape打印验证。
3.4 训练循环:分组学习率、梯度裁剪、线性调度
训练循环里最关键的技巧是分组学习率。BERT 层用较小的学习率微调,BiLSTM 和 CRF 用较大的学习率更新,否则模型容易震荡或收敛过慢。下面这段代码是完整可跑的流程:
from transformers import get_linear_schedule_with_warmup import torch model = NERModel("bert-base-chinese", len(label2id)).to(device) optimizer = torch.optim.AdamW([ {"params": model.bert.parameters(), "lr": 2e-5}, {"params": model.bilstm.parameters(), "lr": 5e-4}, {"params": model.crf.parameters(), "lr": 1e-3}, ]) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_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) emissions = model(input_ids, attention_mask) loss = model.crf(emissions, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad() print(f"epoch {epoch + 1}, loss {loss.item():.4f}")warmup 比例设在 10% 左右,让 BERT 的学习率在最初几百步缓慢上升,避免预训练权重被过大的步长破坏。梯度裁剪值 5.0 是针对 LSTM 结构的经验值,不加的话偶尔会出现 loss 突然变成 NaN 然后一路不收敛的情况。model.crf的成员函数同时也是nn.Module,调用forward时会自动计算 loss,注意传入的labels必须是带 -100 的版本,不能被错误地替换成 0。
4. 训练与评估:实体级 F1 上不去的五个参数,先动哪个
模型能跑起来只是第一步,真正耗时间的是评估和调参。中文电子病历 NER 的评估不能看 token 准确率,因为非实体 token 占比太高,全预测成 O 也能拿到很高的准确率,但实际一个实体都抽不出来。必须按实体级别计算精确率、召回率和 F1 值。
4.1 评估指标不能只看 token 准确率:实体级 P、R、F1 为什么必须算
实体级评估的匹配规则是:一个实体只有当边界和类型都完全一致时才算预测正确。“胃痛”预测成“胃疼”或者边界多一个“部”字,都算预测错误。这样的指标才是业务真正关心的——下游结构化字段必须拿到完整且正确的实体文本。评估时把 BIO 序列还原成实体列表,再逐个比对。肉眼观察几个例子是必要的,但不要替代指标,模型对某个实体类型的高频漏检只有通过分类别指标才能暴露出来。
4.2 五个必调参数:学习率分组、batch_size、max_len、epoch、warmup
| 参数 | 建议初始值 | 调整逻辑 |
|---|---|---|
| BERT 学习率 | 2e-5 | 超过 5e-5 大概率不收敛,优先往下调 |
| BiLSTM/FC 学习率 | 5e-4 | 与 BERT 层分开设置,收敛速度差异明显 |
| CRF 学习率 | 1e-3 | 转移矩阵参数少,可以稍大 |
| batch_size | 32 | 显存够就翻倍,不足就 16 |
| max_len | 128 | 按句长分布调,过长就切句 |
| 梯度裁剪 | 5.0 | 出现 NaN 时优先降 LSTM 学习率 |
实际调参时我先动 BERT 学习率。很多情况下 F1 卡在 0.6 附近上不去,是 BERT 层学习率偏大导致微调不充分,降到 1e-5 后效果有明显改善。epoch 不要盲目加到 20 以上,医疗标注数据量通常几千句,BERT 微调太多次容易过拟合,建议配 early stopping,观察验证集 F1 连续三轮不涨就停。
4.3 训练过程可视化:loss 降但 F1 不涨,先查标签分布
训练时把 loss 曲线和验证 F1 曲线同步画出来。最常见的问题是 loss 平稳下降,但验证 F1 一动不动。此时先看标签分布:统计每个实体类型在训练集里出现多少次,如果“检查”类实体只有几十条样本,模型大概率学习不到。另外一个常见原因是标注不一致,同一个实体在不同标注员手里一个标“检查”一个标“检验”,模型无所适从。可视化 loss 曲线还有个作用:判断学习率是否过大。loss 曲线出现明显锯齿状震荡,通常说明步长太大,需要降低学习率。
4.4 实体级 F1 计算:seqeval 库一行代码拿到分类别指标
训练完一轮后,立刻把验证集的预测结果整理成 BIO 序列格式,用 seqeval 直接计算分类别指标:
from seqeval.metrics import classification_report # true_tags: [[B-symptom, I-symptom, O, ...], ...] # pred_tags: [[B-symptom, I-symptom, O, ...], ...] print(classification_report(true_tags, pred_tags))true_tags和pred_tags是二维列表,每个元素是句子级的标签序列。注意 seqeval 只接受字符串标签,不接受数字 id,转换时用id2label反向映射。分类别报告会输出每个实体类型的 P、R、F1 和样本量,比只看整体 F1 更有价值。整体 F1 可能是 0.75,但“症状”实体 0.85、“药品”实体 0.6,这种差距直接决定后续要不要补数据。
5. 常见问题与避坑:这个方案最容易翻车的五个实际位置
这个方案跑通不难,跑好很难。以下五类问题是我在实际项目中遇到过、且反复被其他团队问到的,每条都按现象、原因、解决来写。
5.1 标签错位:loss 正常下降,验证 F1 却在 0 附近
现象:训练 loss 能正常下降,但验证 F1 始终在 0.1 以下,或者实体边界普遍偏移一个字。
原因:BERT 切词后的 token 数量和原始字符数量不一致,标签没有对应扩展。最常见于英文缩写、数字和特殊符号,这些内容被 WordPiece 拆成两个以上 token,但标签只赋了一个,导致之后所有 token 的标签整体错位。中文部分大部分是单字,这类问题容易被忽略,一旦出现就影响整句。
解决:Dataset 里逐字循环,tokenize 后按子 token 数重复标签,这在 3.1 节已给出代码。另一个隐蔽点:padding 位置要设为 -100,如果设成 O 类标签的 id,CRF 会把 padding 当成真实文本参与计算,导致验证集指标失真。
5.2 torchcrf 库在 Python 3.10 以后编译失败
现象:pip install torchcrf成功,但 import 后报错,或者运行时出现 ninja 编译错误,错误信息指向某个 C++ 源文件。
原因:torchcrf 包含 C++ 扩展,新版 PyTorch 和 Python 3.10 以上的 API 变化导致其源码无法直接编译。这个问题在多人协作环境中反复出现,换了机器又得折腾。
解决:不再依赖第三方库,直接用 3.3 节的自定义 CRF 实现。它大约 120 行,训练速度和 torchcrf 没有可感知的差别,还解决了跨平台兼容问题。如果你的环境能用 torchcrf,也不一定要换,但团队里有人升级环境后很可能会被动踩坑。
5.3 长文本截断后句尾实体全丢
现象:训练时把所有句子硬截到 128,验证时发现句尾实体全部漏掉,F1 长时间卡在某个数值上不去。
原因:电子病历里一句话超过 200 字很常见,症状描写、现病史、既往史往往堆在一句里,实体集中在句子后半部分。max_len=128直接把这些实体切掉了,模型从未见过句尾的标签,自然学不会。
解决:先按句号、分号切句,切成短句后再进入模型,这是最推荐的做法。如果一句话太长且语义不可分割,用滑窗分段并留 20 字的重叠,推理时把重复预测的实体做去重合并。显存允许时把max_len提到 256 也能缓解,但不根治,切句才是正解。
5.4 评估和推理两套解码方式不一致,F1 虚高
现象:训练时验证 F1 显示 0.78,部署到推理接口后肉眼效果明显差一截,用户很快怀疑模型能力。
原因:训练评估时用的是logits.argmax(-1)逐位置选标签,没有走 CRF 的维特比解码,可能输出非法标签序列;而推理时走了crf.decode,两套逻辑结果不一致,实际效果低于验证指标。
解决:评估和推理统一走model.crf.decode,并把评估代码固定为模板,任何人接手都跑同一套逻辑。实体级 F1 的计算也建议写成一个单独函数,避免每次评测手搓导致结果不可比。
5.5 O 类占绝对多数,模型退化成全预测 O
现象:模型把所有 token 都预测成 O,或者只抽出一两个高频实体。P 值看着高,R 值低到不能看,整体 F1 在 0.2 附近。
原因:病历里 O 类 token 占比常常超过 80%,症状和药品等实体虽然业务上重要,但样本量相对少。CRF loss 对所有类别一视同仁,模型只要多预测 O 就能降低整体 loss,走向“省事”的局部最优。
解决:先把实体类型的频次分布打出来,确认哪些类型严重不足。数据层面,增加低频实体类型的样本,或者对相关类型做同义词合并;算法层面,可以引入类别权重或 Focal Loss,但会改动 loss 计算逻辑,成本较高。先补数据永远是性价比最高的方案。
6. 模型落地与进阶:把推理脚本做成接口,再考虑换预训练模型
训练完的模型最终要变成能被业务调用的一行函数。推理脚本的关键点在于:输入的原始文本和标注时的字符级对齐必须完全一致。下面的infer_one函数直接从文本到实体列表,适合接 Flask 接口或离线批处理。
def infer_one(text, model, tokenizer, id2label, max_len=128): model.eval() char_tokens = [] for char in text: sub_tokens = tokenizer.tokenize(char) if not sub_tokens: sub_tokens = [tokenizer.unk_token] char_tokens.extend(sub_tokens) tokens = [tokenizer.cls_token] + char_tokens[: max_len - 2] + [tokenizer.sep_token] input_ids = torch.tensor(tokenizer.convert_tokens_to_ids(tokens)).unsqueeze(0) attention_mask = torch.ones_like(input_ids) with torch.no_grad(): logits = model(input_ids, attention_mask) pred_ids = model.crf.decode(logits, attention_mask)[0] labels = [id2label[i] for i in pred_ids[1 : len(tokens) - 1]] text_len = min(len(text), max_len - 2) entities = [] idx = 0 while idx < text_len: if labels[idx].startswith("B-"): etype = labels[idx][2:] end = idx + 1 while end < text_len and labels[end] == f"I-{etype}": end += 1 entities.append({"type": etype, "text": text[idx:end]}) idx = end else: idx += 1 return entities这个函数直接把预测的标签序列还原成实体字典列表。pred_ids[1 : len(tokens) - 1]去掉 CLS 和 SEP 对应的预测标签,再逐个字符归并成实体。中文逐字场景下标对齐没问题,如果输入中包含英文单词、数字被拆成多 token,上述简化版可能错位,此时需要把 3.1 节的对齐逻辑复制到推理端,保持训练和推理的完全一致。
下一步的进阶方向有三个。第一,换用roberta-wwm-ext或医疗领域预训练模型,通常能在现有基础上提升 1 到 2 个点。第二,加入对抗训练或 R-Drop 这类正则化技巧,在标注数据只有几千句时能抑制过拟合。第三,如果业务需要“腹痛伴恶心”同时抽出多重嵌套实体,现有 BIO 方案做不到,可以考虑指针网络或级联模型,但工程量会明显上升。我自己的经验是:先把基础方案的 F1 压榨到极限,再考虑换模型,不要一开始就上复杂结构。每次调完一组参数,我都会把当前最优配置留在脚本注释里,方便回滚对比。希望这些内容能帮你在中文电子病历命名实体识别这条路上少走几步弯路。
本文还有配套的精品资源,点击获取