上个月帮朋友复现一份CHARLS数据分析,光是算Framingham风险评分就耗了两个晚上。核心问题不在算法——计分公式早已公开可查,真正折磨人的是数据本身:血压测了三次到底用哪一次、总胆固醇单位怎么换算、吸烟和糖尿病的判定口径在不同问卷里不完全一致。这些细节任何一个出错,审稿人只要抽查一例就能看出来,整个结果的可靠性都会被打上问号。
那段时间我干脆把整套计算过程封装成了一个可复用的函数charls_framingham_score。它面向CHARLS数据的实际结构做了适配,只要把整理好的列名传进去,几秒钟就能得到每个人的评分点数和10年心血管疾病风险概率。不少做数据分析的朋友看到之后问我底层逻辑和具体用法,这篇就把函数的设计思路、操作流程、以及我在真实数据上踩过的坑完整梳理一遍。
1. 手动计算Framingham评分到底难在哪:CHARLS数据的三大现实问题
1.1 数据不是"拿来就能用"的格式
CHARLS作为大型追踪调查项目,原始数据结构非常庞杂。单一主题的问卷变量分散在不同模块,体检数据、血检数据和家庭问卷又往往分属不同的文件。你在做Framingham评分时,起码要同时满足年龄、性别、总胆固醇、高密度脂蛋白胆固醇(HDL-C)、收缩压、降压药使用情况、吸烟状态、糖尿病状态这几类信息。把这些变量从原始问卷中一一提取并合并到同一张表上,就是第一道坎。
很多人第一次处理CHARLS时都会犯一个相同错误:以为下载一个dta文件直接读进来就能开始算。实际上,基线数据和随访数据要按个人ID精确匹配,不同批次变量名还可能带有不同后缀,甚至同一生理指标在两次测量中的字段含义也有细微差异。如果合并逻辑不严谨,极容易出现ID错位、变量串行的问题。这类错误最危险——计算本身没有任何报错,但结果完全不可用。
1.2 评分模型有多套版本,参数口径经常混用
Framingham风险评分在文献中有多个版本。用得最广的是ATP III版本,它基于Wilson在1998年提出的框架,针对的是10年冠心病风险;而D'Agostino在2008年提出的改良版本则覆盖更广泛的心血管事件,也就是大家常说的"一般心血管疾病风险"。
这两个版本的计分权重完全不同,年龄段划分也不同。有些人做CHARLS分析时,参考A论文用了ATP III的计分表,却在讨论里引用了B论文中基于D'Agostino模型的风险阈值——这属于典型的版本混用,在评审阶段极其致命。我在函数里默认采用ATP III框架计算传统Framingham评分点数,同时保留对D'Agostino风险表进行映射的选项,用输出结果的字段加以区分,从源头上避免口径混淆。
1.3 缺失和异常值会悄无声息地污染结果
CHARLS是实地调查数据,存在大量缺失、拒绝回答和逻辑跳转。血压可能测了三次但某一次读数异常,血检报告中的总胆固醇可能有超出合理生理范围的离群值,吸烟问卷里还区分"现在吸烟""过去吸烟""从不吸烟"。如果不对这些情况做明确规则,评分函数会在缺失值面前报错,或者更隐蔽——用错误值算出一个看似合理、实际离谱的风险概率。
charls_framingham_score在设计时就把这些问题作为一等公民处理,而不是假设输入数据已经完美整洁。下面我会逐个拆解它是怎么处理这些现实问题的。
2. charls_framingham_score的核心设计:变量映射、计分逻辑与输出结构
2.1 函数的输入参数不是随便传的
先给一个最简调用示例,再解释每个参数的含义:
from charls_framingham_score import charls_framingham_score result = charls_framingham_score( data=df, age_col="age", sex_col="gender", tc_col="chol_total", hdl_col="chol_hdl", sbp_col="sbp_mean", bp_treat_col="bp_med", smoking_col="smoker_current", diabetes_col="diabetes_flag", mode="atp3" )data接收一个标准的DataFrame,各行代表一个受访样本。其余列参数指定对应的原始字段名。这里的关键是:函数内部不会假设你的列名,它完全依赖你传入的映射关系。这样做的好处是适应不同年份CHARLS数据之间变量名的变化,比如2011年基线和2013年随访的血压变量命名规则略有不同,只需要调整传入的列名,核心计算逻辑完全复用。
sex_col的编码也在内部做了兼容,既接受男性为1、女性为0的数据格式,也接受男性为"1"、女性为"2"的字符型格式,甚至还能识别人数为"Male"/"Female"的字符串。让数据预处理环节少一层编码转换,也能避免在性别语义上产生歧义。
2.2 ATP III计分表的内部实现逻辑
函数内部按照性别分别维护计分表。以基线场景中常见的40-79岁年龄段为例,主要变量包括年龄、总胆固醇、HDL-C、收缩压、吸烟和糖尿病。每一项落在不同区间获得相应分数,最后累加。
年龄分组在不同性别中的积分曲线并不对称。比如在ATP III框架下,男性年龄每跨越一个年龄段分数递增幅度明显,女性则在绝经后阶段风险权重上升更快。这种性别差异不是统计噪声,而是Framingham原始队列中真实的风险模式,所以实现时必须严格区分两套计分表,不能为了简化合并成一套。
总胆固醇的计分区间分为<160、160-199、200-239、240-279、≥280五档,HDL-C则分为<40、40-59、≥60三档。收缩压的计分会额外结合是否正在服用降压药,服用者与未服药者的权重完全不同。这里我用一个内部字典来组织计分权重,例如男性中年龄段与总胆固醇维度的交叉计分矩阵,逻辑上是完整对应原表而不是近似拟合。
2.3 输出的结果列是怎么设计的
函数返回的DataFrame在原始数据基础上增加了三列:
| 列名 | 类型 | 说明 |
|---|---|---|
fram_score_points | int | ATP III框架下的累计风险分数,即各单项分数之和 |
fram_score_prob | float | 10年冠心病风险概率,由总分映射得到 |
risk_strata | str | 根据常见临床界值生成的风险分层:"low"、"moderate"、"high" |
fram_score_prob的计算不是简单百分比,而是通过Framingham原始研究中发布的10年绝对风险对应表实现分数到概率的映射。这个映射表在不同性别中是独立维护的,所以函数内部会根据sex_col自动选择对应的查找表。总分如果超出表内上限,按上限风险值处理;总分低于下限,则取下限风险值,保证输出永远落在合理的概率区间内。
risk_strata的界值也有讲究。传统Framingham工具一般分为<10%、10%-20%、>20%三档,但很多医学研究在描述基线特征时会使用不同切点。函数默认按10%和20%这两个常用阈值分三层,同时提供risk_cut_low和risk_cut_high参数允许你自行调整。该字段在设计上定位为辅助描述,不是诊断依据。
3. 完整落地实操:从原始CHARLS数据到一键出分的全过程
3.1 第一步:把问卷数据对齐到统一的个人维度
无论你用哪个wave的CHARLS数据,第一步都建议先把人口学、生活方式、体检和血检数据合并到统一的个人ID维度上。CHARLS中每个人的唯一标识由家庭ID和个人ID组合而成,合并时务必确保使用两者拼接后的ID,单纯使用家庭ID会直接把同一家庭成员当作同一个人,造成严重错配。
实际操作中,我习惯先对需要使用的数据文件分别检查ID变量的唯一性,再执行merge。不同来源的数据可能存在重复ID条目,比如一个人在同一次wave中有两套体检记录,这种数据需要先按规则去重或决定保留哪一套,以免合并后行数膨胀。合并完成后,检查行数是否等于基线样本量,这一步看似基础,却能拦截掉大量合并异常。
3.2 第二步:构建函数要求的核心变量
评分模型需要的最小变量集就是七个:年龄、性别、总胆固醇、HDL-C、收缩压、降压药使用、吸烟、糖尿病。对于CHARLS,这些变量的常见来源如下:
- 年龄:由出生年份与调查年份计算,注意CHARLS问卷中年龄字段是变化的,建议根据出生日期动态计算。
- 性别:大部分wave里有直接字段,清洗时统一编码。
- 总胆固醇与HDL-C:来自血检数据。注意血检报告的单位通常是mg/dL,国内医院习惯使用mmol/L,函数内部不做自动单位假设,需要你在预处理时统一。
- 收缩压:CHALRS体检部分通常有多次读数。多数学术论文使用第二次或第三次读数的平均值,也有研究使用三次读数平均,我建议你明确写明自己选取的规则,并且保持前后一致。
- 降压药使用:来自问卷部分,需根据是否服用降压药物构建二值变量。
- 吸烟:常见口径是"当前是否吸烟"。注意要区分"现在吸"与"过去吸",Framingham标准模型只把当前吸烟视为风险因素。
- 糖尿病:可以使用自报诊断、空腹血糖、糖化血红蛋白等口径。自报比较简单,但在以血糖值判定时,需设定明确阈值。
这一步是最耗时、最琐碎的部分。做一个辅助函数先把这些字段从原始数据中构建出来,再统一格式转给charls_framingham_score,会让后续迭代轻松很多。
3.3 第三步:调用函数并核对返回结果
预处理完成后,调用函数本身非常简单。以合并好的DataFrame为例:
scored_df = charls_framingham_score( data=merged_df, age_col="age_calc", sex_col="gender_num", tc_col="tc_mgdl", hdl_col="hdl_mgdl", sbp_col="sbp_mean", bp_treat_col="anti_htn_med", smoking_col="smoke_now", diabetes_col="diab_final", mode="atp3" )跑完之后不要急着拿去分析,先做几项合理性检查。看fram_score_prob的分布范围,绝大多数40岁以上人群的10年风险应该在1%到50%之间,如果出现大量超过80%甚至等于100%的情况,几乎可以肯定是变量构造出了问题。再按性别分别检查均值,男性通常高于同龄女性,三个月内没有生理机制让这个趋势反转。
我曾在一份数据里发现50岁以上男性的平均风险概率高达61%,当时第一反应是计分表映射出错,但排查后发现问题出在糖尿病变量:把空腹血糖值偏高的受试者全部标记成了糖尿病患者,导致风险权重被系统性放大。所以评分结果本身也是对预处理质量的检验,异常分布往往指向数据构造环节,而不是函数本身。
3.4 纵向数据的批量处理
CHARLS是多期追踪调查,不少研究会同时使用两到三个wave的数据。函数在纵向场景下同样适用:只要在不同wave的数据上分别做合并和预处理,再按ID纵向拼接即可。唯一要注意的是,Framingham公式本身是针对10年风险设计的,直接计算每个wave的风险概率在语义上各有独立含义,不能把一个wave中10年风险较高就解读为另一wave的预测变化。更稳妥的做法是计算完毕后把风险分层当作时间变化的状态变量来使用,而不是进行差值的简单计算。
4. 真实数据上最容易翻车的三类问题:缺失值、单位换算与判定口径
4.1 血压变量:多次测量到底取哪个
CHARLS的血压测量通常在同一访问中重复多次,常见的有三次。不同论文的取法有三种:取第一次,取第二次,取三次平均值。这三种方式在数值上差异可能不大,但在高龄人群中血压波动明显,可能直接影响一个人的风险分层。比如收缩压135和142在计分权重上可能跨档,对应分数发生变化,最终导致风险从低危变成中危。
函数本身不会替你决定取哪个值,因为研究设计的要求可能各有不同。但你可以在预处理阶段统一处理。我的建议是:如果你们课题组的统计计划书写的是"以三次测量的平均值作为分析用血压",那就严格计算平均值;如果没写,直接取后两次的平均值在流行病学中更常见,因为第一次测量往往包含适应效应,数值偏高。
比取哪个值更隐蔽的问题是:有些人三次测量并不完整,可能只有两次甚至一次有效记录。对这类个体,简单取平均会引入偏倚。我的处理习惯是:如果有三次,取三次平均;如果只有两次,取两次平均;如果只有一次,就保留这一次并单独标记,让审稿人能明确看到你的缺失策略,也方便主分析进行敏感性检验。
4.2 总胆固醇和HDL-C:单位换算差一点,风险差一档
Framingham计分表里的胆固醇区间是基于mg/dL设计的,CHARLS血检数据通常也报告为mg/dL,但国内不少医院和研究机构习惯于mmol/L。两者换算系数是乘38.6(准确说是mmol/L→mg/dL乘38.67)。比如某人总胆固醇5.0 mmol/L,换算后大约是193 mg/dL,在ATP III表里落在160-199档;如果不换算直接按5.0进表,就会落在最低档<160,评分直接少几分。
这个错误极其常见,因为数值刚好在正常范围内,不会触发任何程序报错,混在数据流里很难被发现。我把单位转换放在函数外的预处理阶段来做,就是为了让转换逻辑对使用者可见。强烈建议你在代码里写明确注释并在最终表格中单独列出血脂列的单位,无论论文还是内部复核,都能一眼检查清楚。
另外还需要留意一点:部分CHARLS配套数据集中的血脂变量已经换算成了国际标准单位,或者用不同字段名区分原始单位和导出单位。合并前一定要读变量说明文档,确认使用的到底哪一列,不能想当然地把"chol"开头的字段都当成同一单位。
4.3 吸烟和糖尿病:判定口径直接决定模型的预测方向
吸烟变量在不同wave中的问卷设计有一定差异。一部分wave里区分"现在吸"与"以前吸",另一部分则直接问"是否吸烟"。Framingham模型在计分表中用的是"当前吸烟"这一二值状态,所以函数内部要求smoking_col对应的列务必是0/1二值编码,其中1代表"当前正在吸烟"。
有人会把"过去吸烟但已戒烟"也计为1。这个偏差的影响比想象中大:吸烟在计分表中通常占据数分权重,放在10年风险上可能让一个本处于中危的患者被划入高危。审稿人如果看到戒烟者的吸烟标志被置为1,会直接质疑数据处理可靠性。
糖尿病判定则是另一个重灾区。CHARLS中有自报确诊、血糖值、糖化血红蛋白等多重信息来源。Framingham原始模型使用的糖尿病定义是"临床诊断或正在接受降糖治疗",也就是说它是一个二元状态,不是血糖数值本身。如果你用空腹血糖阈值来判定,一个未诊断但血糖偏高的个体就会被错误地标记为糖尿病患者。
解决思路是:优先使用自报确诊或降糖药物使用记录构建糖尿病变量。如果样本量不够,必须用血糖值辅助判定,建议同时对齐ADA或中国糖尿病防治指南的阈值标准,并在代码注释中写明依据和判定规则。两个口径可以各建一版变量,主分析使用临床诊断口径,敏感性分析使用血糖辅助口径,这样结果更扎实。
5. 评分到手之后:风险分层描述、合理性验证与进一步扩展
5.1 把10年风险概率变成论文里的基线表格
拿到fram_score_prob之后,最常见的用途是作为基线特征纳入论文的Table 1。很多文章会报告全人群和分性别的平均10年心血管风险,以及低危、中危、高危三组占比。这里有个特别容易被忽略的细节:Framingham风险评分是为无心血管疾病史人群设计的,如果你纳入了既往有心肌梗死或脑卒中病史的受访者,计算出来的风险对这些个体没有临床预测价值,必须事先排除或用单独类别描述。
推荐的表格呈现方式是按性别分层报告。因为评分本质上是性别特异性的,直接合并全人群均值会抹掉这种结构差异。我的做法是分别计算男性、女性各自的风险概率均值和标准差,再用t检验或非参数检验比较组间差异。不同的CHARLS分析中常见的结果模式是女性平均风险低于男性,但如果年龄结构差异大,女性老年占比高时,这一差异也可能被部分抵消。
risk_strata三分类同样适合做Table 1分组变量。你可以按低危、中危、高危分别列样本量、年龄均值、性别占比等,直观展示不同风险层级的人群画像。后续如果要做关联分析,比如探讨某行为因素与心血管风险的关联,这个分层变量也可直接作为有序多分类变量进入模型。
5.2 怎么验证你算出来的风险是靠谱的
我每次拿到新函数或新数据跑完分,都会执行三道验证。先把全样本的风险概率分布画出来,看是否有明显的极端堆积。正常数据下,40岁组风险应该集中在低值区域,70岁以上逐渐抬高,曲线整体应单调。如果70岁以上出现回落,往往是年龄字段计算有误或样本构成异常。
第二道验证是把你的评分和CHARLS官方或已发表文献中的总体分布做粗略对比。北大相关团队和众多基于CHARLS发表的心血管论文,通常会报告分析人群的平均Framingham风险概率。不必要求完全一致——随访年份、纳入排除标准、变量定义都可能引起差异——但数量级不应差出10个百分点以上。如果差异过大,优先检查变量口径而不是模型实现。
第三道验证是内部一致性抽查。另写一段代码,从总样本中随机抽取几十人,用临床线上Framingham计算器手工核对其中几例。即使只核对5例,也能有效发现比例因子放错、维度错位等大问题。
5.3 从Framingham评分出发还能做哪些扩展
如果你熟练掌握这套数据整理与评分流程,后续扩展其实水到渠成。CHARLS本身包含的身体功能量表、抑郁症状、社交参与、睡眠时长等模块,都可以与Framingham风险分层做交叉分析。由于二次分析本身不是新的队列研究,你的核心竞争力就在于把评分变量用得干净、透明。
对R用户,也可以把同一套逻辑改造为R函数;对需要极简操作的人,还可以进一步将评分函数封装进shiny应用或Python Web API,让其他课题组成员通过界面提交CSV文件直接拿结果。这样处理的好处是,数据清洗责任依然在数据提供方,而评分计算得到统一口径,不再出现三个人跑出来三种版本的情况。
我个人在实际操作中的体会是:任何评分工具的价值,都建立在数据口径的严格定义之上。charls_framingham_score解决的只是从"七个字段"到"三个结果指标"这最后一段路的机械化工作,真正决定分析质量的是你在预处理环节是否把每个变量的语义都搞得明明白白。建议在使用前,把这个函数在自己的数据上跑通一次全空值测试——如果对所有变量都输入空值,函数应该返回空结果而不是哑值,这个行为本身就是对你的数据管线的第一层安全检查。