多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

pycrfsuite实战:中文医疗命名实体识别与知识图谱构建

pycrfsuite实战:中文医疗命名实体识别与知识图谱构建 简介天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的糖尿病相关医疗命名实体识别项目面向参赛开发者及NLP入门者基于pycrfsuite实现CRF序列标注方案。压缩包共1699个文件以784个csv数据文件、485个txt文件、421个ann标注文件为主体另有7个Python脚本、1个Jupyter Notebook和1个Markdown说明整体约11.65MB目录结构清晰便于按流程复现。已有145人学习下载。资源包含完整设计文档、源代码与预处理后的数据可帮助读者理解特征工程、模型训练与实体标注格式之间的衔接也可为医疗知识图谱构建中的实体抽取环节提供可运行的参考实现。1. 天池瑞金医院MMC大赛初赛里的糖尿病NER为什么老牌pycrfsuite反而值得写一个周五下午我把标注好的糖尿病电子病历切成“字 BIO标签”喂给pycrfsuite跑了90轮lbfgs验证集F1停在了86.3。同一份数据用微调过的BERT再跑一轮也就87出头训练时间和显存开销却翻了好几倍。这就是天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的典型场景代码包里要完成的是糖尿病相关医疗命名实体识别而数据量不大、标注噪声不小这时候pycrfsuite这样轻量的CRF库反而更能打。它不需要GPU特征可解释参数翻车后还能直接看错误样本定位问题。这篇笔记就把我常用的一套做法拆开实体体系怎么定、特征模板怎么写、参数怎么调、坑在哪里最后落到知识图谱的实体数据输出上。适合手里有一批标注文本、想快速跑通NER并准备做图谱落地的工程团队。2. 实体体系与标注方案把糖尿病相关语料拆成可训练的BIO数据2.1 先定实体边界七类实体够用别学论文里分十六类做医疗NER最容易犯的错是参考论文把实体类别拆得特别细结果CRF在细粒度区分上频繁翻车。初赛背景是MMC国家标准化代谢性疾病管理中心的糖尿病管理数据文本里大量出现主诉、现病史、诊断、用药和检查报告。我一般会把实体收敛成七类既覆盖赛题评审能看见的实体又保证标注一致性和模型可分性。实体类型典型示例边界注意点疾病2型糖尿病、糖尿病肾病、视网膜病变不要把“并发”拉进实体症状多饮、多尿、体重下降四字成语式症状容易把主语带进去药物二甲双胍、胰岛素、阿卡波糖剂型字要不要并进去必须全程一致检查项目空腹血糖、糖化血红蛋白“糖化”和“血红蛋白”经常被拆开检查指标值7.8mmol/L、HbA1c 8.5%数值带单位正则可以先抽出来身体部位胰腺、视网膜、下肢容易和症状实体连在一起科室内分泌科、眼科很少单独作为重点但图谱里有用这七类在糖尿病文本里分布比较均衡。如果某个类别只有几十条样本我通常的做法是暂时并入相近类别等数据量涨了再单独拆出来。CRF是线性链模型它学的是相邻字之间的转移关系类别越碎混淆矩阵越大训练数据不够时反而互相拖累。2.2 标注粒度字符级BIO是中文医疗NER的默认选择中文医疗命名实体识别不建议用词级BIO因为分词错误会直接传导给NER。pycrfsuite本身不关心你输入的是字还是词它只看到特征和标签但字符级标注能把“糖化血红蛋白”这类专有名词按字建模遇到词典外的术语时前后字特征还能兜底。标注文件我习惯用最简格式一行一个字加标签空行表示句子边界。空 腹 血 糖 7 . 8 m m o l / L 糖 化 血 红 蛋 白 偏 高 。上面只是一行字序列的示意图。落到训练文件里每个字符占一行标签列写在第二列句子之间留一个空行。字符和标签之间用空格或Tab分隔都可以但训练和预测必须保持一致否则load数据时就会错位。我见过有人训练时用\t分隔、预测时用空格分隔结果标签序列全乱了这种低级问题最耗时间。2.3 构造训练集句子切分与文档级划分拿到原始病历文本后第一件事不是写特征而是把长文本切成句子。病历里的主诉、现病史经常是一大段话直接整段训练会让CRF的上下文窗口被无关内容稀释训练速度也慢。我一般用标点符号切句句号、分号、问号都算边界逗号先不切因为症状枚举往往靠逗号连接。切完之后超过80个字的句子再按逗号做二次切分。import re def split_sentences(text: str, max_len: int 80) - list: parts re.split(r(?[。?!]), text.strip()) sents [] for part in parts: if len(part) max_len: sub_parts re.split(r(?[,]), part) sents.extend([s for s in sub_parts if s.strip()]) else: sents.append(part) return [s for s in sents if s.strip()]切句是辅助手段真正的坑在数据划分。如果一个患者的病历被同时分进训练集和验证集模型在验证时等于开卷考试F1虚高几个点都不奇怪。天池初赛数据是打包好的文本文件没有给你显式的患者ID时我建议至少按病历文件名或段落原文哈希值做分层划分。我习惯先把所有句子按来源文档分组再用group划分保证同源数据不跨越数据集边界这一步能直接避免后面“验证集86测试集79”的尴尬。3. pycrfsuite特征工程字特征、窗口与词典特征决定F1上限3.1 特征模板怎么定当前字、前后2字、词性与边界字pycrfsuite的F1上限七成由特征模板决定剩下三成才是参数和训练数据的功劳。深度学习里模型自己学特征CRF里特征全靠人写所以这部分要花最多心思。我的默认模板以字符为中心取前后两个字的窗口叠加上下文二元特征、词性特征和数字标点标记。窗口为什么取2因为“盐酸二甲双胍缓释片”这类常用药物实体最长也就八九个字当前字左右各看两个字基本能覆盖实体内部的局部依赖窗口再宽特征维度爆炸小数据上很容易过拟合。import jieba.posseg as pseg BOUNDARY_CHARS set(病素片苷醇症肿痛膜眼足) def get_pos_tags(sent: str) - list: pos_tags [] for word, pos in pseg.cut(sent): pos_tags.extend([pos] * len(word)) # 个别情况下pseg会把标点合进词长度可能不等于len(sent) if len(pos_tags) ! len(sent): pos_tags [x] * len(sent) return pos_tags def word2features(sent: str, i: int, pos_tags: list) - list: features [ fw{sent[i]}, fw-1{sent[i-1] if i 0 else BOS}, fw1{sent[i1] if i len(sent)-1 else EOS}, fw-2{sent[i-2] if i 1 else BOS}, fw2{sent[i2] if i len(sent)-2 else EOS}, fw-1:w{sent[i-1] sent[i] if i 0 else BOS}, fw:w1{sent[i] sent[i1] if i len(sent)-1 else EOS}, fw-1:w:w1{sent[i-1] sent[i] sent[i1] if 0 i len(sent)-1 else BOUNDARY}, fpos{pos_tags[i]}, fpos-1{pos_tags[i-1] if i 0 else BOS}, fpos1{pos_tags[i1] if i len(sent)-1 else EOS}, fis_digit{sent[i].isdigit()}, fis_alpha{sent[i].isalpha()}, ] if sent[i] in BOUNDARY_CHARS: features.append(is_boundary_char1) return features def sent2features(sent: str) - list: pos_tags get_pos_tags(sent) return [word2features(sent, i, pos_tags) for i in range(len(sent))]这三段代码里最重要的是特征命名格式。pycrfsuite要求每条特征是一个可哈希的对象通常是字符串。我把特征名和特征值拼成一个形如w-1:w空腹的字符串好处是在训练日志里能直接看到某个特征被模型当成强证据可解释性比向量输入强得多。w-1:w:w1这种三元特征会大幅增加特征维度小数据集上建议先不加等基线跑通后再打开对比F1变化。词性特征我用的是jieba的pseg它返回的每个词会展开成等长的字符标签序列这样保证pos_tags和句子字符数对齐。BOUNDARY_CHARS是医疗文本里的一类关键边界字“病、素、片、症”这些字经常出现在实体末尾把它们单独做成布尔特征等于直接告诉CRF“这里有可能是实体右边界”。3.2 词典与规则特征把高频医学术语变成硬锚点字符级特征能学到字与字之间的转移模式但对“二甲双胍”“糖化血红蛋白”这种强领域词纯统计特征需要很多样本才能记住。常见做法是准备一份高频术语词典在特征提取时做子串匹配命中就把词典类别拼进特征。词典不用大一百到两百个从训练数据里统计出来的高频医学术语就够重点是精确宁可漏掉也别错因为一个错误词条会对所有包含它的句子产生稳定干扰。MED_DICT [2型糖尿病, 糖尿病肾病, 糖尿病视网膜病变, 糖化血红蛋白, 空腹血糖, 二甲双胍, 阿卡波糖, 胰岛素, 内分泌科] def build_dict_features(sent: str, i: int) - list: feats [] for term in MED_DICT: term_len len(term) if i term_len len(sent) and sent[i:i term_len] term: feats.append(fdict_match{term}) if i term_len - 1 and sent[i - term_len 1:i 1] term: feats.append(fdict_tail{term}) return feats这个函数返回的不是单一值而是一组特征。dict_match表示当前字是词典词的首字dict_tail表示当前字是词典词的尾字。一个词条命中会对词内多个字产生不同特征CRF自然能学到“出现在词典词中间的字更可能是I-标签”。词典特征在预测阶段必须原样加载所以我会把MED_DICT单独放一个文件训练和预测都从文件读取而不是在训练脚本里定义一份、预测脚本里又定义一份。我在这上面栽过跟头训练时词典带“二甲双胍”预测时漏了一版更新结果实体召回直接掉了四个点。3.3 特征一致性训练和预测必须走同一条Pipeline写到这里必须单独强调一个问题这是CRF项目最后悔药都救不回来的坑训练特征函数和预测特征函数是两份代码。有人训练脚本里调用了word2features预测脚本里为了省事把特征拼成了另一个顺序pycrfsuite不会报错但模型在预测时拿到的特征ID映射完全错位输出标签基本等于随机。我的习惯是把特征工程全部放进一个feature.py模块训练和推理都import同一份代码并在启动时对同一条句子打印一次特征输出人工确认两边一致。def sanity_check(): sample 空腹血糖7.8mmol/L糖化血红蛋白偏高 feats sent2features(sample) for i, f in enumerate(feats[:5]): print(i, sample[i], f) if __name__ __main__: sanity_check()这段检查代码不是锦上添花而是每次跑新数据前必做的一步。特征工程展开后字符串特征可能有十几维任何一维拼接顺序变了肉眼很难发现但模型效果会明显变化。把特征提取函数收敛到一个模块里再配合一次性输出打印基本能从源头堵住这个坑。4. 训练与调参把lbfgs跑起来并让实体级F1稳过854.1 从标注文本到pycrfsuite训练实例数据管线特征工程准备好后下一步是把标注文本转成pycrfsuite能吃的训练实例。pycrfsuite的输入分两部分特征序列和标签序列。特征序列是一个二维列表外层按句子组织内层是每个字符的特征列表标签序列就是每个字符对应的BIO标签组成的列表。训练集里包含多个句子就多次调用trainer.append(xseq, yseq)。def load_annotated_file(path: str): sentences, labels [], [] cur_x, cur_y [], [] with open(path, encodingutf-8) as f: for line in f: line line.rstrip(\n) if not line: if cur_x: sentences.append(.join(cur_x)) labels.append(cur_y) cur_x, cur_y [], [] continue char, label line.split() cur_x.append(char) cur_y.append(label) if cur_x: sentences.append(.join(cur_x)) labels.append(cur_y) return sentences, labels sentences, labels load_annotated_file(train_data.txt) train_data [] for sent, label_seq in zip(sentences, labels): if len(sent) ! len(label_seq): continue train_data.append((sent2features(sent), label_seq))加载函数按空行分句这个约定要和标注环节保持严格一致。有个容易被忽略的点line.split()默认按空白切分如果标注文件里字符和标签之间既有空格又有Tab都能切对。但我在预处理时常看到有人把特征文件和标签文件分开存这不是不行而是增加了对齐出错的概率。更稳妥的做法是始终保存成“字 标签”的单一文件特征在运行时生成。标注文件里偶尔出现整行只有标签没有字或者一句话长度和标签数对不上这类脏数据要在这里直接过滤掉不要等训练时报错再回头查。4.2 参数解释c1、c2、minfreq、possible_transitionspycrfsuite训练时的可调参数不多每一个都值得讲清楚。算法我固定用lbfgs它在中小数据集上收敛稳定比paPassive-Aggressive效果通常更好。c1是L1正则系数控制特征权重的稀疏度c2是L2正则系数抑制权重过大。特征爆炸时优先调c2而c1我一般不开因为CRF特征已经是显式构造的离散特征稀疏性天然存在再上L1反而容易把弱信号特征直接清零。import pycrfsuite trainer pycrfsuite.Trainer(verboseTrue) for xseq, yseq in train_data: trainer.append(xseq, yseq) trainer.set_params({ algorithm: lbfgs, c1: 0.0, c2: 0.1, max_iterations: 150, feature.minfreq: 0, feature.possible_states: True, feature.possible_transitions: True, }) trainer.train(mmc_diabetes_ner.crfsuite)参数的实际取值按数据规模调整。我一般在5000到20000条句子上把c2设为0.1起步验证集F1不升就调大到0.5再观察log-loss曲线的下降幅度。max_iterations在lbfgs下不一定要跑满CRFSuite内部有收敛判定训练日志里看到loss变化小于阈值就会提前停。但如果数据量大日志里到150轮还在缓慢下降可以开到300。feature.minfreq是一个容易被忽略的参数它表示特征在训练集中出现的最小频次设为0表示保留所有特征。特征维度高到几十万时可以试着设为2或3把只出现一两次的噪声特征丢掉训练速度和泛化能力都有改善。feature.possible_transitions和feature.possible_states建议都设为True。前者让模型学习标签之间的转移矩阵后者让每个标签拥有自己的状态特征。初赛数据上possible_transitionsTrue能把“O后面直接跟I-标签”这类非法转移压下去对实体边界的提升非常明显几乎是无本万利。4.3 实体级P/R/F1评估初赛榜单看的是实体级别的精确率、召回率和F1不是token级。两个指标差距很大token级只要一个字预测对了就贡献一点实体级则要求整个实体边界的标签序列完全正确。用token级指标评估你甚至没法发现模型把“糖化血红蛋白”拆成两段的问题。所以评估代码必须自己写不能用sklearn的classification_report糊弄。def extract_spans(labels: list) - list: spans [] start -1 for idx, label in enumerate(labels): if label.startswith(B-): if start ! -1: spans.append((start, idx - 1)) start idx elif label.startswith(I-): if start -1: start idx else: if start ! -1: spans.append((start, idx - 1)) start -1 if start ! -1: spans.append((start, len(labels) - 1)) return spans def evaluate_entity(preds, golds): tp, fp, fn 0, 0, 0 for pred, gold in zip(preds, golds): p_spans set(extract_spans(pred)) g_spans set(extract_spans(gold)) tp len(p_spans g_spans) fp len(p_spans - g_spans) fn len(g_spans - p_spans) p tp / (tp fp) if tp fp else 0 r tp / (tp fn) if tp fn else 0 f1 2 * p * r / (p r) if p r else 0 return p, r, f1这个评估函数把标签序列先转成实体跨度集合再按集合运算算P/R/F1。extract_spans里对“I-开头但前面没有B-”的情况做了兜底直接按实体起点处理。实际数据里偶尔会有标注错误导致标签序列以I-开头如果不去兜底一个实体的跨度会被整体漏掉评估结果会异常偏低。初赛拿到基线后我会把预测结果和真实标注逐条比较按实体类别拆开看哪类实体召回低再针对性补词典特征或调整标注规范而不是盲目调参数。5. 医疗NER避坑手记标签漂移、O膨胀与五个修复动作5.1 验证集F1有88测试集只剩79这个现象我碰到过太多次基本可以断定是数据划分泄漏。初赛提供的原始文本往往按病历文件组织同一份病历的多个句子之间有大量重复的上下文表述。如果切句后直接随机划分训练集和验证集里会出现来自同一病历的高度相似句子模型等于提前看过答案。解决方法是先按文件名或段落来源分组把整组放进同一个数据集。分组后如果验证集句子数和之前差很多说明原始划分确实泄漏了。from sklearn.model_selection import GroupShuffleSplit groups [doc_id for doc_id, _ in enumerate(sentences)] splitter GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(splitter.split(sentences, labels, groups))用GroupShuffleSplit保证组级别的划分。代码里groups的长度要和句子数一致同一个病历的所有句子用同一个ID。这种划分方式虽然会让训练集数量变少但验证集分数和测试集分数的相关性会显著提高竞赛里这种一致性比多几百条训练数据更重要。5.2 模型把“二甲双胍片”拆成“二甲双胍”和“片”这个现象背后是标注规则不一致。有人给“二甲双胍片”整体标成药物有人只把“二甲双胍”标成药物、把“片”留在O里还有人把剂型标成身体部位。CRF在特征层面看到同样的上下文却要学两种不同的边界模式最终只能取一个折中结果。解决的唯一办法是统一标注规范并写一个后处理脚本把“药物实体 剂型字”合并。DOSE_WORDS [片, 胶囊, 缓释片, 注射液, 颗粒] def merge_dose_spans(entities: list) - list: merged [] for ent in entities: if ent[type] 疾病: merged.append(ent) continue # 实体文本以剂型字结尾时向前扩展一位到实体内部 for dose in DOSE_WORDS: if ent[text].endswith(dose): ent[text] ent[text][:-len(dose)] merged.append(ent) break else: merged.append(ent) return merged这段后处理比较粗但能救回一部分被拆开的实体。更根本的解决办法是在标注阶段就把“剂型算作药物实体一部分”写成规范让所有标注员按同一个标准执行。规范定得越死CRF学到的边界越干净。还有一类类似问题是“糖尿病”和“2型糖尿病”在文本里同时出现时模型有时只识别出短实体这时候靠特征没用需要直接在词典特征里把长词条放在前面优先匹配。5.3 O标签太多模型学成永远预测O糖尿病病历文本里实体占比通常只有10%到20%剩下全是O标签。类别不均衡不会让CRF崩溃但会让模型倾向于保守预测把边界不确定的字都判成O精确率看着还行召回率惨不忍睹。pycrfsuite不像深度模型那样有class_weight参数我常用的兜底方案是调整训练样本分布把完全没有实体的句子过滤掉一部分让有实体句子在训练集中占更高比例然后训练后统计发现过滤比例要控制在10%以内过度过滤会让模型在纯O句子上的误报变多。另外就是给验证集评估时单独看每个实体类的召回率如果“检查指标值”这类带数字的实体召回特别低多半是数字特征没有区分度需要在特征里加上“数字后紧跟单位”这类组合特征。5.4 log-loss降不下去特征维度爆炸有一版特征我加了w-1:w:w1三元组合后训练集log-loss下降到0.3附近就再也动不了验证集F1反而掉了两个点。原因很简单特征维度从几万涨到几十万样本量没变模型开始记忆训练集中的偶发特征。解决方法是调大c2到1.0同时打开feature.minfreq丢掉低频特征。这两个参数一旦改动观察的指标不是训练集loss而是验证集实体级F1。trainer.set_params({ algorithm: lbfgs, c2: 1.0, feature.minfreq: 2, max_iterations: 100, })c2从0.1调到1.0听起来跨度很大但特征维度已经爆炸的情况下正则强度不够等于没调。minfreq2会把只在一条句子里出现过的特征全部丢弃这些特征本来就是噪声的记忆单元。如果同时出现两个问题先调minfreq再调c2因为minfreq是直接删特征训练速度快反馈周期短。5.5 pycrfsuite安装版本坑训练和预测环境不一致pycrfsuite已经很多年没有大版本更新在Python 3.9以上的环境里经常没有预编译wheelpip install时直接现场编译报错信息五花八门。最常见的是缺swig和C编译链Windows下尤其折磨。我现在的习惯是直接用conda装Python 3.8环境跑CRF这个版本下pycrfsuite的wheel基本都有。还有个隐藏坑训练时用conda装的pycrfsuite 0.9.7预测时换了台机器用了0.9.6模型文件虽然能打开但个别特征解析行为有差异线上F1会有零点几个点的波动。做竞赛或项目交付时训练和预测环境最好用同一份requirements锁版本。6. 从识别结果到知识图谱落点把BIO序列转成可用于neo4j构建的实体数据6.1 输出归一化把BIO序列合并成实体表初赛的命名实体识别只是第一步天池瑞金医院MMC大赛整体的落点是辅助构建知识图谱。知识图谱构建的开端是把预测出的BIO序列还原成结构化实体这一步做得不好后面neo4j导入的实体表就是脏数据。我的做法是写一个后处理脚本把每个句子的预测标签和原始文本、文档ID、句子偏移量一起输出成实体表按患者维度聚合去重。def spans_to_entities(sent: str, labels: list, doc_id: str, sent_offset: int) - list: entities [] start -1 for idx, label in enumerate(labels): if label.startswith(B-): if start ! -1: entities.append({ doc_id: doc_id, start: sent_offset start, end: sent_offset idx - 1, text: sent[start:idx], type: labels[start][2:] }) start idx elif not label.startswith(I-): if start ! -1: entities.append({ doc_id: doc_id, start: sent_offset start, end: sent_offset idx - 1, text: sent[start:idx], type: labels[start][2:] }) start -1 return entities输出字段里doc_id和start/end偏移量是生成知识图谱边的关键有了它们才能在原文中回溯验证实体也能按患者把相同实体聚合。实体去重时不能只按字符串去重“2型糖尿病”和“II型糖尿病”在原文里是两种写法但图谱里应该是同一个节点。常见做法是维护一份实体别名映射表把写法和格式归一到标准名再入图这一步在初赛阶段能大幅提升图谱的可用性。6.2 关系抽取的轻量方案用共现与句法线索生成候选边知识图谱只有实体没有关系充其量是一张术语表。但初赛拿到的语料通常没有关系标注直接上BERT关系分类模型又费时间。我一般先用规则和共现生成候选三元组保留人工抽验的入口。最轻量的规则同一句子里出现的两个实体如果它们的实体类型属于“疾病到症状”“药物到疾病”这种常见组合就生成一条候选关系。再配合触发词比如“合并”“并发”“导致”出现的地方关系类型可以直接映射。REL_TRIGGERS {合并: 并发症, 并发: 并发症, 导致: 诱因, 伴: 伴随} def extract_relation_candidates(sent: str, labels: list, doc_id: str) - list: entity_spans spans_to_entities(sent, labels, doc_id, 0) triples [] for trigger, rel_type in REL_TRIGGERS.items(): if trigger in sent: head [e for e in entity_spans if e[end] sent.index(trigger)] tail [e for e in entity_spans if e[start] sent.index(trigger)] for h in head[-1:]: for t in tail[:1]: triples.append((h[text], rel_type, t[text])) return triples触发词规则简单但误配率高一句“伴肾功能不全”里“伴”字前后其实可能没有实体。所以我在代码里只取了触发词左右最近的一个实体并且要求头实体类型必须落在“疾病、药物、检查项目”这三类里尾实体落在“症状、疾病”里。这样生成的边噪声会小很多。真正要进入图谱的边还是需要人工抽检一个子集修正初赛阶段能把候选边准确率做到80%以上已经能满足“辅助构建”这个定位。6.3 导入neo4j与验证一个最小Cypher命令脚本实体和关系整理成CSV后导入neo4j就是最后的收尾。neo4j构建知识图谱最常用的是LOAD CSV它适合小规模入图不用重启数据库改完还能重跑。实体CSV和关系CSV各准备一个文件实体CSV只保留实体ID、标准名、类型三列关系CSV保留头尾实体ID和关系类型。LOAD CSV WITH HEADERS FROM file:///entities.csv AS row CREATE (e:Entity {id: row.id, name: row.name, type: row.type}); LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (a:Entity {id: row.head_id}) MATCH (b:Entity {id: row.tail_id}) MERGE (a)-[r:RELATED {type: row.rel_type}]-(b);两段Cypher分别导实体和关系。第一段创建节点第二段先匹配两端的实体再MERGE建边。这里用MERGE而不是CREATE是为了避免重复跑脚本时生成重复边。实体CSV里如果有重名实体CREATE会生成多个同name节点所以入图前一定要先在Python侧按标准名去重。导入完成后随手跑一句MATCH (e:Entity) RETURN e.type, count(*)看看每类实体数量如果数量分布和训练集统计差异不大说明NER结果基本可靠。我现在的习惯是每次跑完新模型先打印一份边界错误样本再看实体类型分布最后才刷新图谱数据。这三步做完图谱才不会变成模型的垃圾场。希望帮到你。本文还有配套的精品资源点击获取
返回列表