医数智联·第 17 期:Python + 首页数据自动校验 —— 300 行代码,救回 N 千万医保损失
[!ABSTRACT] 核心摘要
本期主题 :Python 自动校验首页数据——从「人工抽查 5%」到「机器全检 100%」
核心结论 :首页校验的本质 = 「规则 + 自动化」 ,把 50+ 条校验规则写成 Python 脚本,5 分钟跑完 1 万份病案
5 大校验类型 :必填校验、格式校验、范围校验、关联一致性、ICD 编码映射
实战效果 :从「每月抽查 100 份」变成「每月全检 1 万份」,问题发现率提升 100 倍
阅读时长 :约 10 分钟
一、场景引入:为什么医院需要「首页自动校验」?
[!example] 一个真实的月底 月底,医保局要结算上月的病案。
信息科从 HIS 导出 1 万份病案首页,质管办抽查 100 份,发现 12 份有问题 。
按这个抽样率推算,全院可能有 1200 份有问题 ——这些将被医保局拒付 。
1 万份病案,平均每份 5000 元,1200 份 × 5000 = 600 万 。
院长:「为什么不能全检? 」
信息科:「1 万份人工全检要 50 人天。」
院长:「……那先这样吧。」
这是医院首页校验的典型困境——抽样检不出问题,全检成本太高 。
现状
痛点
人工抽查 5%
95% 的问题漏检
人工全检
成本太高,不可持续
抽查 → 整改
总是「事后补救」
医保拒付
一年损失几百万
[!quote] 狼叔的判断“首页校验不是’要不要做’,是’用机器做还是人工做’。” 这一期,带你用 Python 写一个完整的首页校验脚本,300 行代码,5 分钟全检 1 万份 。
二、工具原理:首页校验的 5 大类型 2.1 校验类型 ①:必填校验
[!info] 必填字段不能为空 例:病案号、出院日期、主诊 ICD、责任医生,这 4 个必填,缺一不可 。
2.2 校验类型 ②:格式校验
[!info] 字段格式必须正确
病案号格式:BA + 8 位数字(如 BA20260715001)
出院日期:YYYY-MM-DD
主诊 ICD:字母 + 数字 + .数字(如 I10.x05)
2.3 校验类型 ③:范围校验
[!info] 数值在合理范围
年龄:0-150 岁
住院天数:0-365 天
费用:> 0
2.4 校验类型 ④:关联一致性
[!info] 字段之间逻辑一致
出院日期 > 入院日期
主诊 ICD 与性别一致(如「前列腺疾病」不应出现在女性病案)
主诊与重要操作匹配(如「肺部感染」主诊不应有「心脏手术」)
2.5 校验类型 ⑤:ICD 编码映射
[!info] ICD 编码是否合法
是否在 ICD-10 字典里
是否与诊断名称匹配
是否为有效码(非 xxx.x00 占位码)
三、医院实战:完整的首页校验脚本 3.1 准备数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import pandas as pdimport redf = pd.read_excel('本月首页.xlsx' ) icd_dict = pd.read_excel('ICD-10字典.xlsx' ) valid_icd = set (icd_dict['ICD编码' ]) gender_restricted = { '前列腺' : '男' , '子宫' : '女' , '卵巢' : '女' , '睾丸' : '男' , '妊娠' : '女' , '前列腺增生' : '男' , '卵巢囊肿' : '女' } 外科主诊关键词 = ['骨折' , '损伤' , '创伤' , '肿瘤' , '结石' ] 内科操作关键词 = ['CT' , 'MRI' , '超声' , '化验' , '活检' ]
3.2 校验函数:必填校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def check_required (df ): """必填字段校验""" errors = [] 必填字段 = ['病案号' , '出院日期' , '主诊ICD' , '责任医生工号' ] for idx, row in df.iterrows(): for field in 必填字段: if pd.isna(row[field]) or str (row[field]).strip() == '' : errors.append({ '病案号' : row.get('病案号' , f'第{idx} 行' ), '错误类型' : '必填校验' , '错误描述' : f'字段 [{field} ] 不能为空' , '错误等级' : '严重' }) return pd.DataFrame(errors)
3.3 校验函数:格式校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 def check_format (df ): """字段格式校验""" errors = [] 病案号_pattern = re.compile (r'^BA\d{8,12}$' ) icd_pattern = re.compile (r'^[A-Z]\d{2}(\.\d{1,3})?$' ) for idx, row in df.iterrows(): if pd.notna(row['病案号' ]) and not 病案号_pattern.match (str (row['病案号' ])): errors.append({ '病案号' : row['病案号' ], '错误类型' : '格式校验' , '错误描述' : f"病案号格式错误(应为 BA+8-12 位数字):{row['病案号' ]} " , '错误等级' : '严重' }) if pd.notna(row['主诊ICD' ]) and not icd_pattern.match (str (row['主诊ICD' ]).strip()): errors.append({ '病案号' : row['病案号' ], '错误类型' : '格式校验' , '错误描述' : f"主诊 ICD 格式错误:{row['主诊ICD' ]} " , '错误等级' : '严重' }) return pd.DataFrame(errors)
3.4 校验函数:范围校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 def check_range (df ): """数值范围校验""" errors = [] for idx, row in df.iterrows(): if pd.notna(row.get('年龄' )): 年龄 = float (row['年龄' ]) if not (0 <= 年龄 <= 150 ): errors.append({ '病案号' : row['病案号' ], '错误类型' : '范围校验' , '错误描述' : f"年龄超范围(0-150):{年龄} " , '错误等级' : '一般' }) if pd.notna(row.get('住院天数' )): 天数 = float (row['住院天数' ]) if not (0 <= 天数 <= 365 ): errors.append({ '病案号' : row['病案号' ], '错误类型' : '范围校验' , '错误描述' : f"住院天数超范围(0-365):{天数} " , '错误等级' : '严重' }) if pd.notna(row.get('总费用' )): 费用 = float (row['总费用' ]) if 费用 <= 0 : errors.append({ '病案号' : row['病案号' ], '错误类型' : '范围校验' , '错误描述' : f"总费用异常:{费用} " , '错误等级' : '严重' }) return pd.DataFrame(errors)
3.5 校验函数:关联一致性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 def check_consistency (df ): """关联一致性校验""" errors = [] for idx, row in df.iterrows(): if pd.notna(row.get('出院日期' )) and pd.notna(row.get('入院日期' )): if row['出院日期' ] < row['入院日期' ]: errors.append({ '病案号' : row['病案号' ], '错误类型' : '一致性校验' , '错误描述' : f"出院日期({row['出院日期' ]} )早于入院日期({row['入院日期' ]} )" , '错误等级' : '严重' }) if pd.notna(row.get('主诊ICD' )) and pd.notna(row.get('性别' )): 主诊名称 = str (row.get('主诊名称' , '' )) 性别 = str (row['性别' ]) for 关键词, 限制性别 in gender_restricted.items(): if 关键词 in 主诊名称 and 性别 != 限制性别: errors.append({ '病案号' : row['病案号' ], '错误类型' : '一致性校验' , '错误描述' : f"主诊含[{关键词} ]但患者性别为[{性别} ]" , '错误等级' : '严重' }) break return pd.DataFrame(errors)
3.6 校验函数:ICD 编码映射 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 def check_icd_mapping (df ): """ICD 编码是否合法 + 是否与诊断名称匹配""" errors = [] for idx, row in df.iterrows(): 主诊ICD = str (row.get('主诊ICD' , '' )).strip() 主诊名称 = str (row.get('主诊名称' , '' )).strip() if 主诊ICD not in valid_icd: errors.append({ '病案号' : row['病案号' ], '错误类型' : 'ICD映射' , '错误描述' : f"主诊 ICD 不在字典中:{主诊ICD} " , '错误等级' : '严重' }) continue 标准名称 = icd_dict[icd_dict['ICD编码' ] == 主诊ICD]['诊断名称' ].values if len (标准名称) > 0 and 主诊名称: 关键词 = 标准名称[0 ].replace('其他' , '' ).replace('未特指' , '' )[:4 ] if 关键词 not in 主诊名称: errors.append({ '病案号' : row['病案号' ], '错误类型' : 'ICD映射' , '错误描述' : f"主诊名称与 ICD 不匹配(ICD={主诊ICD} ,名称={主诊名称} )" , '错误等级' : '一般' }) return pd.DataFrame(errors)
3.7 主函数:把所有校验串起来 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 def validate_首页 (文件路径 ): """主函数:运行所有校验,输出错误清单""" print (f"开始校验:{文件路径} " ) df = pd.read_excel(文件路径) print (f"共 {len (df)} 条首页" ) 错误清单 = pd.concat([ check_required(df), check_format(df), check_range(df), check_consistency(df), check_icd_mapping(df) ], ignore_index=True ) 等级顺序 = {'严重' : 0 , '一般' : 1 , '轻微' : 2 } 错误清单['排序键' ] = 错误清单['错误等级' ].map (等级顺序) 错误清单 = 错误清单.sort_values(['排序键' , '病案号' ]).drop(columns=['排序键' ]) print (f"\n=== 校验结果 ===" ) print (f"总错误数:{len (错误清单)} " ) print (f"严重错误:{(错误清单['错误等级' ]=='严重' ).sum ()} " ) print (f"一般错误:{(错误清单['错误等级' ]=='一般' ).sum ()} " ) 类型统计 = 错误清单['错误类型' ].value_counts() print (f"\n错误类型分布:" ) print (类型统计) 输出文件 = '首页校验结果.xlsx' with pd.ExcelWriter(输出文件) as writer: 错误清单.to_excel(writer, sheet_name='错误清单' , index=False ) 类型统计.reset_index().to_excel(writer, sheet_name='类型统计' , index=False ) print (f"\n✓ 结果已保存到 {输出文件} " ) return 错误清单 错误清单 = validate_首页('本月首页.xlsx' )
典型输出 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 开始校验:本月首页.xlsx 共 10250 条首页 === 校验结果 === 总错误数:387 严重错误:215 一般错误:172 错误类型分布: 一致性校验 142 ICD映射 118 格式校验 85 必填校验 28 范围校验 14 Name: count, dtype: int64 ✓ 结果已保存到 首页校验结果.xlsx
四、避坑指南:5 个让校验脚本「失灵」的常见错误
[!warning] 雷区 ①:只校验主诊,漏了副诊症状 :副诊里有「妊娠合并心脏病」,但 ICD 编码错了。解决 :主诊 + 副诊都要校验 ,副诊数量最多 10 条。
[!warning] 雷区 ②:ICD 字典版本不一致症状 :医院用的是 2019 版 ICD-10,字典是 2024 版,编码匹配不上 。解决 :字典版本和 HIS 系统一致 ,写脚本时注明版本号。
[!warning] 雷区 ③:容错处理缺失,一次报错整个脚本死症状 :一行数据格式异常,整个 iterrows() 崩溃。解决 :用 try/except 包裹每行校验 ,异常单独记录。
[!warning] 雷区 ④:校验规则写死,新增规则要改代码症状 :医院新加校验规则,要改 Python 代码,IT 不愿意维护。解决 :把规则配置化 ,用 Excel/JSON 存规则,脚本读取:
1 2 3 4 rules = pd.read_excel('校验规则.xlsx' ) for _, rule in rules.iterrows(): pass
[!warning] 雷区 ⑤:校验完不通知到人症状 :脚本跑完了,结果在 Excel 里,没人看 。解决 :自动发邮件 :
1 2 3 4 5 6 import yagmailyagmail.SMTP('质管办@xxx.com' ).send( to=['编码员@xxx.com' ], subject=f'【首页校验】本月有 {len (错误清单)} 条问题' , contents=['详见附件' , '首页校验结果.xlsx' ] )
五、把脚本嵌入日常工作流
[!TIP] 实战:每月自动校验
每月 1 日 :脚本自动跑,生成错误清单
每月 2 日 :编码员收到邮件,逐条修改
每月 3 日 :重新校验,确认错误清零
每月 5 日 :汇总错误类型,做培训材料
配置方法 :
Windows 任务计划程序 → 每月 1 日 02:00 跑脚本
或用 Navicat + 邮件触发器(详见第 09 期)
六、下期预告
[!quote] 第 18 期Python + DRG 入组分析 —— 让医保结算少扣钱的「代码版」
这一期,带你用 pandas 替代第 10 期的 SQL,做 DRG 入组异常筛查,适合不会 SQL 但会 Python 的医院人。
[!TIP] 互动 如果你觉得这篇「医数智联·第 17 期」对你有用,请点赞、在看、转发 给科里的同事。
文中完整校验脚本 + 校验规则 Excel 模板 + ICD 字典示例,我已经整理成「首页自动校验工具包」。点击「阅读原文」可直接下载 。
我是白衣狼,一个用数字化工具把医院管理做「轻」的实战派。咱们下期见。