资讯动态

Word病历解析实战:从慢阻肺病历.doc到结构化数据与GOLD分级

发布时间:2026/9/18 15:28:03 来源:尧图企业网站定制
简介临床医学教学与病历书写规范参考文档适合医学院校学生、规培生及基层呼吸科医师学习使用。资料以1份doc文件呈现约55KB包含一份完整的慢性阻塞性肺病COPD入院病历内容覆盖主诉、现病史、过去史、个人史、体格检查、辅助检查及入院诊断等全流程书写要素。文中详细记录了反复咳嗽、咯痰、气急、心悸、浮肿、发绀等典型临床表现并展示了从COPD进展为慢性肺源性心脏病、右心衰竭的诊疗思路还涉及抗生素、平喘药、利尿剂等治疗策略及风险因素分析。已有94人学习下载适合用于临床见习、病历书写训练或疾病复习参考。1. 拿到“慢性阻塞性肺病病历.doc”真正的麻烦才刚刚开始一份从医院归档系统里导出的“慢性阻塞性肺病病历.doc”打开看是几页规整的Word文档主诉、现病史、既往史、辅助检查、诊断一串下来中间夹着几张肺功能检查表格。但真要拿它做队列研究、构建专病数据库或训练临床文本模型你会发现最费时间的不是诊断逻辑而是把Word段落里的半结构化文本变成可计算的字段。“慢性阻塞性肺病病历.doc”这类文件通常是旧版二进制格式段落与表格混排日期写法不统一肺功能指标和用药方案藏在长句中间还经常带修订痕迹和批注。这篇文章围绕“如何用脚本把这类病历咬碎、抽干、洗成干净数据”展开先讲Word文件的容器结构和解析选型再给出一套时间锚点、肺功能指标、吸烟史和急性加重次数的抽取方案最后落GOLD分级、ICD-10映射、批量脱敏和质量校验。适合做医疗数据治理、临床科研数据抽取和慢病管理知识库的工程师。2. 开工先分清.doc和.docx从文件结构谈解析选型2.1 扩展名不是唯一标准先看文件头“慢性阻塞性肺病病历.doc”这个文件名很容易骗人。很多从老HIS系统导出的文件虽然后缀是.doc实际可能是RTF、HTM甚至直接是XML真正二进制的OLE2反而不多。解析前先用file命令看一眼真实格式能省掉大量“为什么python-docx读着读着就崩”的排错时间。file 慢性阻塞性肺病病历.doc输出可能是“Composite Document File V2 Document”说明是真.doc、“Rich Text Format”RTF或“Microsoft Word 2007”实际是.docx却改了后缀。不同容器对应的解析路径完全不同。OLE2复合文档要用antiword、LibreOffice或Apache Tika转文本RTF要先做一次格式转换后缀改成.docx但内部是zip包的反而可以直接用python-docx处理。这一步的判断会直接决定整个流水线的第一站是“转换”还是“直接解析”。2.2 解析工具选型按场景挑别迷信万能库工具输入格式输出特点典型用途python-docx.docx段落、表格、样式结构完整不丢顺序结构化抽取首选docx2txt.docx纯文本轻量不做结构区分快速预览、全文索引LibreOffice headless.doc/.docx/RTF可转docx/doc/txt转换后结构可有损老格式转换通道antiword.doc只出文本版本老对中文支持一般应急取文本Apache Tika多格式自动识别并抽取文本和元数据混合格式批处理我做病历解析时默认路线是.docx用python-docx直接读真.doc先丢给LibreOffice headless转成docx再走同一条解析管线。用python-docx而不是docx2txt的原因很实际病历里的“辅助检查”那一块几乎全是表格纯文本抽取会把表格的行列关系和单元格归属全部拍平后面根本没有办法把FEV1和对应日期配对。2.3 段落和表格混排时按文档体顺序遍历python-docx里Document.paragraphs只返回正文顶层段落不包含表格内部的文本tables单独返回所有表格。两份清单各自有序但合起来会丢掉“段落A之后是表格B然后是段落C”的原始顺序。而慢阻肺病历恰恰是主诉段落、肺功能表格、吸烟史段落交替出现顺序错了时间线和指标的对应关系就全乱了。from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.ns import qn doc Document(copd_case.docx) def iter_block_items(parent): parent_elm parent.element.body for child in parent_elm.iterchildren(): if child.tag qn(w:p): yield Paragraph(child, parent) elif child.tag qn(w:tbl): yield Table(child, parent) blocks [] for block in iter_block_items(doc): if isinstance(block, Paragraph): text block.text.strip() if text: blocks.append({type: paragraph, text: text}) else: rows [] for row in block.rows: cells [c.text.replace(\n, ).strip() for c in row.cells] # 合并单元格会导致同一值重复先做连续去重 deduped [] for cell in cells: if not deduped or deduped[-1] ! cell: deduped.append(cell) rows.append(deduped) blocks.append({type: table, rows: rows})这里的iter_block_items是按document.xml里w:p和w:tbl出现的物理顺序逐个产出元素的。单元格去重是因为Word合并单元格后python-docx会把合并区域的值复制到每个格子直接用会造出同一行里多个“肺功能”或一串重复的“FEV1/FVC”。注意row.cells在跨列合并时可能缺少网格信息连续去重法对“相邻且完全相同”的场景有效如果同一个单元格的文本在两列中本来就是重复的也会被误删但病历场景里这种概率很低。2.4 老.doc的转换补救LibreOffice走一遍真.doc文件在python-docx里直接打不开先转换再解析是最省心的路径。soffice --headless --convert-to docx --outdir converted_raw 慢性阻塞性肺病病历.doc转换后的docx里表格结构大概率会被拆分或塞入多余空段落。转换后再跑一遍2.3的遍历你会发现很多单元格尾部带着\n或者同一行被拆成两个表格。我的习惯是转换后先统计表格数量和行数跟原文档核对一遍数量对不上就直接走纯文本正则抽取不再依赖表格行列结构。另外带修订模式的病历在转换时删除线内容会变成w:delTextpython-docx的paragraph.text不会包含这部分但如果你用Tika之类的工具去取文本删除内容可能又被带回来。做数据抽取前统一决定一次删除线内容一律不进结构化结果。3. 信息抽取让时间线和肺功能指标自动落位3.1 先切块再抽取病历小节识别一段完整的慢阻肺病历虽然没有固定模板但“主诉”“现病史”“既往史”“辅助检查”“诊断”这些小节标题基本不会缺席。先按小节把全文切成块后续的正则只在小节内部工作避免“吸烟史”里的年份被误认为检查日期。import re section_pat re.compile( r(主诉|现病史|既往史|个人史|家族史| r体格检查|辅助检查|专科检查|诊断|治疗意见|出院医嘱) ) def split_sections(blocks): sections [] current None for block in blocks: if block[type] ! paragraph: continue text block[text] m section_pat.match(text) if m and len(text) 20: current m.group(1) sections.append({section: current, texts: []}) elif current: sections[-1][texts].append(text) return sections这里用“小节标题行必须短于20字”作为边界条件是因为很多病历会把“辅助检查胸部CT示双肺透亮度增高”整段写在一行里。如果开头匹配到关键词但整行超过20字就当作正文而不是标题。实际数据里“既往史”段落常常包含“有吸烟史30年”“曾因慢阻肺急性加重住院3次”这些对后续归一化至关重要的信息切块后单独处理能显著降低交叉匹配错误。3.2 日期锚点 指标抽取肺功能数值如何绑定检查日期慢阻肺病历里的时间线很特殊现病史里写着“患者于2023-03-12因呼吸困难加重入院”辅助检查表格的某个单元格里可能只有一个孤零零的“1.62”抬头单元格写着“FEV1”。要还原“某次检查的FEV1是多少”首先要做的是在文本流里建立日期锚点再让锚点之后的指标自动归入该日期。pat_date re.compile( r(?Pyear(?:19|20)\d{2})\s*[-/.年]\s* r(?Pmonth\d{1,2})\s*[-/.月]\s* r(?Pday\d{1,2})\s*日? ) pat_fev1 re.compile( r(?:FEV1|第一秒用力呼气容积|一秒量)[^\d]{0,12} r(?Pvalue\d{1,3}(?:\.\d)?)\s* r(?PunitL|mL|升|毫升)?, re.IGNORECASE ) pat_pct re.compile(r占预计值(?Ppct\d{1,3}(?:\.\d)?)%)日期正则的捕获组拆成年月日是为了后续排序时能直接转成datetime.date对象而不是在字符串级别做字典序排序。FEV1模式里用了[^\d]{0,12}做关键词和数值之间的柔性连接因为病历里常见“FEV1约1.62L”“FEV11.62升”这两种写法。占预计值单独抽取它要绑定到离它最近的前一个肺功能指标上不能独立存字段。比如“FEV1 1.62L占预计值51%FVC 2.36L占预计值72%”这行文本如果只按位置关系配对第二个“占预计值”会错误绑回FEV1。def extract_spirometry(text): results [] last_metric None for m in pat_fev1.finditer(text): last_metric { metric: FEV1, value: float(m.group(value)), unit: m.group(unit) or L, } # 占预计值回调绑定 for m in pat_pct.finditer(text): if last_metric: results.append({ metric: last_metric[metric], value: last_metric[value], unit: last_metric[unit], pct_predicted: float(m.group(pct)), }) last_metric None return results这段代码的策略是“找到占预计值后再回头绑定最近指标”并且绑定一次就清空last_metric避免同一个FEV1被多个占比值重复消费。这样处理后“FEV1/FVC 68%”这种百分比不会干扰FEV1的值抽取因为模式开头只匹配FEV1或中文别名不会匹配“FEV1/FVC”。这里有个已知边界如果某份病历写“FVC占预计值98%”而前一个指标是FEV1我的方案会误绑。改进方式是同时捕获所有指标关键词并按原文出现顺序排序取最近的“指标占比”对。病历结构化投射总是从80%正确率起步剩下的靠人工清单兜底不要追求一个正则解决所有书写习惯。3.3 多份病历时间线合并与异常值体检单份病历的时间线抽取出来后跨文档合并时真正要盯的是两类错误一是日期解析到月份只有两位比如“2023.4”这种写全年缺日用0填充二是同样指标在同一天出现两个互相矛盾的值比如上午床边肺功能和下午肺功能室的正式报告数值不同。校验项规则处理方式日期合法性month 1-12, day 1-31不合法置空标记需人工FEV1合理区间0.5L 到 5.0L超出整段抽检FEV1/FVC 合理性该比值应小于 100%作为贴合度提示同日期同指标重复值不一致时保留晚出现的标记冲突把这些检查写成一个validate_record函数跑批时输出一个异常报告而不是直接跳过错用人工判断去兜底。合并后的时间线结构我一般存成JSON Lines每行一个患者检查事件方便后续入数据库或对接统计脚本。4. 归一化与GOLD分级病历文本变成可统计数据4.1 “二十年”“30余年”模糊时间表达的统一病历里几乎不会写“吸烟28年3个月”常见的都是“吸烟20余年”“间断咳嗽、咳痰10多年”。这些表达对人类医生不算问题但进了统计模型就是脏数据。def parse_rough_years(text): m re.search(r(?Pnum\d{1,3})\s*[余多多]?\s*年, text) if not m: return None num int(m.group(num)) if 余 in m.group(0) or 多 in m.group(0): return num 2 return num遇到“20余年”我按22年折算遇到“多年”没有数字则记None并在输出里加rough_estimate: true标志。这个“加2”不是临床金标准只是让数值能参与计算的一致化策略所以要把估算标识一起存下来下游做倾向性评分或回归时可以用是否估算作为协变量或者直接排除估算样本做敏感性分析。4.2 吸烟指数pack-years计算的单位陷阱吸烟史在慢阻肺病历里以两种形式出现“吸烟30年每日20支”或直接写“吸烟指数600”。前者需要计算后者是医生已经给的结论。pack-years的计算公式是“年数 × 每日支数 / 20”但很多中文病历写着“每日吸纸烟1包”或“20支/天”或“半包/天”格式五花八门。def calc_pack_years(years, daily_amount, unit支): if unit 包: daily_cigarettes daily_amount * 20 else: daily_cigarettes daily_amount return round(years * daily_cigarettes / 20, 1)这个函数本身简单难在从文本里同时抽取年数和日吸烟量。我一般会写一个复合正则分两个捕获组提取“年”和“支/包”比如吸烟(?Pyears\d{1,3})[余年]*[每日每天日均]*.*?(?Pn\d{1,2})\s*(?Punit支|包)但一旦句子改成“每日吸烟20支共30年”顺序反转就匹配失败。更稳的做法是先按“吸烟”关键词切句再在句子的局部范围内分别找年份和数量的两个独立正则互不依赖顺序。宁可多匹配出几条候选也不要静默漏算。4.3 ICD-10主诊断映射与GOLD分级落地慢阻肺的ICD-10编码里最需要区分的是J44.0伴急性下呼吸道感染、J44.1伴急性加重和J44.9未特指。病历的诊断栏可能只写“慢性阻塞性肺疾病”但在现病史里能查到“本次因受凉后咳痰加重伴发热”这类描述这对应J44.0。抽取诊断时不能只读诊断小节要把整个文书的急性加重线索汇总到一起判断。病历线索判断依据编码建议痰量增加、脓性痰、发热感染证据J44.0呼吸困难加重无感染描述急性加重证据J44.1只有稳定期症状无加重描述未特指J44.9GOLD分级是另一个维度的结构化工作依据肺功能FEV1占预计值百分比。把前面抽到的pct_predicted直接映射def gold_grade(fev1_pct): if fev1_pct is None: return GOLD_NA if fev1_pct 80: return GOLD 1 if fev1_pct 50: return GOLD 2 if fev1_pct 30: return GOLD 3 return GOLD 4映射逻辑用的是GOLD 1到4的传统分级但在输出里保留原始FEV1%值和分级两者不要只留下分级标签。理由有两个一是GOLD 2023之后的评估框架已经改为ABE分组强调急性加重史和症状负担mMRC/CAT单看FEV1%会低估高风险患者二是下游做纵向随访分析时连续变量比等级变量提供更多统计功效。5. 批量跑批前的三道关脱敏、异常隔离和结果校验5.1 姓名、号码与身份证脱敏病历是受控数据脚本处理完通常还要落盘抽取结果和中间文本必须做脱敏。姓名脱敏的策略是保留姓氏后字替换成星号这样后续无论做人工复核还是NLP实体识别语义仍然连续不至于“张**”变成完全无意义的符号串。import re pat_phone re.compile(r(?!\d)1[3-9]\d{9}(?!\d)) pat_id re.compile(r\b\d{17}[\dXx]\b) pat_name re.compile(r(?:患者|病人)姓名[:]?\s*([\u4e00-\u9fa5]{2,4})) def mask(text): text pat_phone.sub(lambda m: m.group(0)[:3] **** m.group(0)[-4:], text) text pat_id.sub(lambda m: m.group(0)[:6] ********* m.group(0)[-2:], text) def mask_name(m): name m.group(1) return m.group(0)[:m.start(1) - m.start(0)] name[0] * * (len(name) - 1) text pat_name.sub(mask_name, text) return text电话号码的脱敏规则保留前3位和后4位身份证保留前6位和后2位中间全部打星。注意pat_name只匹配“患者姓名张三”“病人姓名李四”这种带前缀的写法文书中直接写“张三男67岁”不在覆盖范围内这类需要结合字典或上下文规则做二次扫描但对批量脱敏而言先守住格式明确的字段已经能堵住大部分外泄风险。5.2 批处理与异常隔离批量处理几十份文档时最忌讳的就是一个异常中断整条流水线。实务做法是逐文件try/except异常文件名和错误类型写入日志处理成功的文件写进输出目录失败的原文件待在原处不动等跑批结束后集中人工处理。import traceback from pathlib import Path raw_dir Path(raw_docs) out_dir Path(structured_out) fail_log [] for path in sorted(raw_dir.glob(*.doc*)): try: converted convert_if_old(path) blocks extract_blocks(converted) sections split_sections(blocks) records [] for sec in sections: if sec[section] 辅助检查: records.extend(extract_spirometry(\n.join(sec[texts]))) out_path out_dir / (path.stem .json) out_path.write_text(json.dumps(records, ensure_asciiFalse, indent2)) except Exception: fail_log.append({file: str(path), error: traceback.format_exc()})这段流水线里每份文档独立写JSON不累积在内存里。异常日志用traceback.format_exc()保留完整堆栈而不是只记录错误类型这样看日志时不用再重跑一遍才能定位问题。5.3 双解析路径做字段级校验最后一层检验我采用双轨解析同一份docx一条走python-docx表格解析另一条走LibreOffice转换为纯文本后再用正则抽取。两条路径的结果转成字典后做字段级对比不一致的字段就是高风险点。def field_diff(rec_a, rec_b, fields): diffs [] for f in fields: va, vb rec_a.get(f), rec_b.get(f) if va ! vb: diffs.append({field: f, text_path: va, table_path: vb}) return diffs字段级对比的适用性取决于原始文件的质量。格式规整的病历两条路径结果一致率通常在95%以上不一致最多的是“主诉”这类长文本字段因为段落转纯文本和表格解析的换行处理逻辑不同导致字符串拼接差异而不是真正的抽取错误。看到fev1或pct_predicted这类数值字段出现不一致就要警觉直接输出到人工复核清单。跑批之后我会先按差异字段数量排序处理完差异最多的那批文件再去看全部结果这是每次跑批最花时间但最能沉淀规则的环节。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价