最近有个空气污染与健康数据项目,分析目标和工具逻辑都很对得上。之前我也在不少公共健康数据上用过R语言做分布滞后非线性模型,也就是DLNM,这次借着实际项目把整个流程完整过了一遍,从数据清洗到动态效应可视化,再到结果解读和政策层面怎么转化,能说的细节还挺多。这篇文章就把这套实战路径整理出来,重点放在模型怎么构建、交叉基参数怎么选、图怎么画,以及哪些坑必须绕开。
2. 数据分析方法背景与设计思路
2.1 为什么普通回归解决不了“滞后”和“非线性”
空气污染对健康的影响几乎从来不是“当天暴露、当天出事”这么简单,更常见的情况是前一天甚至前几天的PM2.5、PM10浓度会持续影响今天的心血管和呼吸系统健康。传统的线性回归如果直接把当天污染浓度放进模型,等于默认了“效应发生在同一天”,这明显和生理机制对不上。而且污染物浓度和健康风险之间也完全不是一条直线,低浓度阶段每上升10μg/m³的风险变化,和在高浓度区间同样上升10μg/m³,幅度往往不一样,有时差距还很大。这两件事叠加在一起,普通GLM或者一般的时间序列回归基本就无能为力了。
DLNM的核心思路就是在同一个模型框架里同时处理非线性和滞后效应。它通过交叉基函数把二维的“暴露-滞后-风险”关系展开到回归模型中,相当于在一个模型里同时估计出暴露维度上的剂量反应曲线,以及滞后维度上的时间延迟曲线,这两个维度还可以互相影响,比如某天的高浓度污染可能在3天后才出现峰值效应,也可能在当天就迅速反应,这个“动态变化”的过程普通模型无法刻画。
2.2 DLNM在项目里承担的角色
这个项目的数据结构是典型的城市尺度时间序列数据,某城市连续数年的每日空气污染物浓度、气象指标,以及对应日期的健康结局数据,比如医院急诊量、呼吸系统疾病就诊量或死亡人数。我们的核心研究目标并不是构建一个高精度的预测模型,而是回答三个问题:污染物浓度和健康风险之间到底是什么形状的关系;污染物对健康的影响会持续多少天;在高浓度事件发生后,健康风险在时间上如何累积和消退。这三个问题本质上都指向“动态效应”这个关键词。
从方法路径来看,DLNM负责把污染的短期健康效应转化为可供定量比较的相对风险,即用一个暴露参考值比如10μg/m³作为基线,计算当浓度上升到某一数值时,健康结局发生的相对风险增加多少。这个相对风险会随滞后天数变化,因此可以绘制动态效应三维曲面图。政策层面关注的核心指标也来自这个曲面,包括最大风险发生的滞后天数、累计效应总量,以及是否存在明显的阈值浓度。有了这些数,后续的预警建议和标准讨论才有依据,而不是拍脑袋定。
3. 数据准备与环境配置
3.1 数据集的格式要求
DLNM对数据格式的要求不算复杂,但有几个细节必须提前处理好。最低要求是一份按日期排列的表格,每一行代表一天,核心变量至少包括日期、污染物浓度、健康结局计数,以及可能影响结局的混杂因素,主要是温度和湿度。日期需要连续,因为滞后变量本质上是当天的值往前回溯若干天,如果中间缺了日期,crossbasis函数在构造交叉基时会因为错位而出错,或者更麻烦的是不会被报错,但结果完全失真。
我自己在项目里习惯先做三个检查:日期是否严格连续且无重复,用seq.Date比对一下就能发现;污染物和健康结局是否存在缺失值,数据量不大时可以选择剔除,时间序列较长时优先用插值,但插值要谨慎,不能用简单的线性填充去处理高污染事件日的浓度;确认变量单位统一,比如PM2.5是用μg/m³还是mg/m³,报告相对风险时基准不同,结果差异会非常大。这些基础工作多花半小时,比建模后再回头排查舒服太多。
3.2 R包安装与示例数据
DLNM在R语言中对应的核心包就是dlnm,由模型提出者Gasparrini团队维护,主函数包括crossbasis、crosspred和crossreduce。另外还需要splines包做自然三次样条基础函数,模型拟合用基础R自带的glm即可。包的安装非常简单,但强烈建议用install.packages("dlnm")安装稳定版,不要手动编译GitHub版本,除非你确实需要未发布的新功能。
dlnm包自带经典的芝加哥空气污染与死亡数据chicagoNMMAPS,包含1987年到2000年的每日PM10、臭氧、温度、露点温度以及全死因死亡人数,非常适合拿来跑通完整流程。实际项目里换成自己的数据,本质上就是替换数据源和变量名,整个建模逻辑完全一致。加载数据后用str()和summary()检查数据结构,确认哪些变量是数值型、哪些需要因子化,比如星期几通常要转成因子。代码参考如下:
library(dlnm) library(splines) data(chicagoNMMAPS) str(chicagoNMMAPS) summary(chicagoNMMAPS) chicagoNMMAPS$date <- as.Date(chicagoNMMAPS$date) chicagoNMMAPS$dow <- factor(chicagoNMMAPS$dow)3.3 混杂因素的选取
空气污染健康效应的时间序列分析里,混杂因素处理得好不好直接影响核心效应估计的可靠性。长期趋势和季节性是最重要的一层混杂,城市死亡人数本身就有冬夏波动和逐年变化,这种时间维度上的自然波动如果不加控制,会被错误分配到污染变量上。常规做法是用日期变量构造自然三次样条,自由度按每年4到7个来选取,14年的数据用50到100个自由度属于常见范围。
气候因素尤其是温度和湿度也必须进入模型,温度与死亡风险之间本身就存在经典的U型关系,同时还和污染物浓度高度相关,不调整温度的话,污染物的效应会被严重夸大。实际操作中温度可以直接用自然三次样条纳入,污染物的交叉基则负责核心暴露效应。星期效应也很重要,死亡和就诊数据在周末和工作日往往有系统性差异,因子变量纳入即可。这些混杂因素在glm公式中直接写出来,代码结构看起来复杂,但逻辑上就是叠加了多层控制。
4. 核心建模过程与参数设计
4.1 crossbasis交叉基的构建原理
交叉基是DLNM最核心的环节,理解它之后整个模型就没什么秘密了。构造思路分两步走:先对暴露变量也就是污染物浓度做一次基函数展开,比如自然三次样条或多项式,得到几个不同的暴露基向量,描述浓度和风险之间的非线性关系;再对每一个暴露基向量沿滞后时间维度做第二次基函数展开,得到不同滞后天数下的延迟基。两次展开做张量积,最终生成的是一个覆盖“暴露浓度×滞后天数”二维空间的多列矩阵,这个矩阵被叫作交叉基。
把这个交叉基矩阵放进一个广义线性模型里,每个列都对应一个回归系数,模型拟合完毕后,我们可以还原出任意暴露浓度和任意滞后天数组合下的效应估计值。这么做的好处在于,非线性与滞后效应不再需要人为指定具体的函数形式,比如不用提前假设高峰期是在第几天,数据会告诉我们答案。自由度参数的设定在这里起到了控制曲线平滑度的作用,自由度越大曲线越灵活,但也越容易拟合噪声。
4.2 参数选择的实战经验
交叉基涉及三个关键参数:最大滞后天数LAG、暴露维度自由度、滞后维度自由度。最大滞后天数的选择应当基于专业背景而不是纯统计判断,PM10和PM2.5这类空气污染物的急性效应通常在两周内基本消退,项目里我一般先设定lag=14或lag=21,建模后再观察高滞后日的置信区间是否已经宽到没有信息量,以此验证滞后长度是否设定过长。温度的滞后效应比污染要长不少,热效应和冷效应可以持续数周,如果项目中同时处理温度和污染,温度部分用lag=21或更长会更合理。
自由度方面,暴露维度自由度在3到5之间是常用区间,df=4足以刻画常见的近似线性或缓S形剂量反应关系。滞后维度自由度同样在3到5之间,df越大越能刻画复杂的多峰延迟结构,但对数据量要求也高。如果数据量不大,一味增加自由度只会让曲线变得神经质,出现不符合医学常识的剧烈摆动。实际项目中比较稳健的做法是设置几组候选参数,比如df组合(3,3)、(4,4)、(5,5),分别建模后比较核心效应曲线的变化幅度,如果不同设定下累积效应的置信区间大部分重叠,说明结果对参数不敏感,可以放心选取中间那组。
4.3 完整建模代码
用chicagoNMMAPS示例数据跑一个基础DLNM模型的代码并不冗长。构造交叉基时,暴露维度用自然三次样条,自由度4,滞后维度同样用自然三次样条,自由度4,最大滞后天数定为14天。注意滞后的起点用0,表示包含当天,因为颗粒物污染对心血管系统的急性刺激往往当天就会显现。
cbpm10 <- crossbasis( chicagoNMMAPS$pm10, lag = 14, argvar = list(fun = "ns", df = 4), arglag = list(fun = "ns", df = 4) ) summary(cbpm10) df_time <- 5 * (max(chicagoNMMAPS$year) - min(chicagoNMMAPS$year) + 1) model <- glm( death ~ cbpm10 + ns(temp, df = 6) + ns(date, df = df_time) + dow, family = quasipoisson(), data = chicagoNMMAPS, na.action = "na.exclude" ) summary(model)glm函数中专门指定family为quasipoisson,是因为每日死亡或就诊数据往往存在过度离散现象,即方差大于均值,拟泊松分布在估计时不要求严格满足泊松分布假设,同时又不需要额外估计负二项模型那样复杂。模型里对日期做了自由度为50的时间样条,用来吸收长期趋势和季节性,其中5是每年的自由度,14是数据年份数,这个设置可以按数据实际长度灵活调整。温度直接用自由度为6的自然三次样条纳入,星期变量dow已经转成因子,模型会自动生成哑变量。
4.4 用crosspred提取核心预测结果
模型拟合完还谈不上完成,还必须从模型对象中提取出可解释的效应估计值。crosspred函数的作用就是在给定的暴露值序列上逐一计算所有滞后天数的风险估计,计算累积效应,并输出置信区间。这里有一个非常容易出错的细节是参考浓度的设定,模型的相对风险必须有一个参照基准,否则估计结果只是绝对风险变化,很难读懂。
我用PM10作为暴露变量时一般用10μg/m³作为参考浓度,也有一部分研究会用更低的浓度做参考。代码中的cen参数就是干这个用的,它告诉模型把风险都转换成相对于该浓度的比值。at参数用于控制要预测哪几个暴露水平,通常是从数据最小值到最大值按一定步长取序列,比如每5或10个单位取一个点。cumul=TRUE表示同时计算累积滞后效应,这个累积效应在后续解读总负担时非常关键。
pred <- crosspred( cbpm10, model, at = seq(0, 150, by = 10), cen = 10, cumul = TRUE ) summary(pred)得到pred对象后,可以用names(pred)查看里面有哪些可用内容,比较重要的是predvar表示的暴露预测值序列,matRRfit是各暴露水平和各滞后天数组合下的相对风险矩阵,matRRlow和matRRhigh是对应置信区间,cumRRfit是各暴露水平下的累计相对风险。这些矩阵是后续所有可视化的原料,理解它们的形状和含义对出图很有帮助。
5. 可视化实现与代码解析
5.1 三维效应面与等高线图
可视化是DLNM分析中最直观、也是传播度最高的环节。标准做法先画出三维曲面图,x轴是污染物浓度,y轴是滞后天数,z轴是相对风险,整个曲面能同时呈现两个维度的动态变化。dlnm包自带的plot函数对pred对象提供了专门的绘图方法,ptype="3d"直接输出一个可以旋转视角的透视图。
plot(pred, ptype = "3d", main = "PM10对死亡风险的动态效应面", xlab = "PM10 (μg/m³)", ylab = "滞后天数(天)", zlab = "RR")三维图的问题在于它在论文或报告中并不容易读懂,特别是当曲面有两个峰时,角度一偏就会遮挡关键区域。所以我更常用等高线图作为论文主图,同一个预测对象一行代码就能画出来。等高线的颜色深浅代表风险大小,x和y方向上的变化一目了然,很适合展示风险随滞后天数的增减节奏。
plot(pred, ptype = "contour", main = "PM10健康效应等高线图", xlab = "PM10 (μg/m³)", ylab = "滞后天数(天)", key.title = title("RR"))但有一个小坑是默认绘图中的中文字体在部分系统上会显示为方块。解决方案是一方面把图里的中文说明改为英文,另一方面在出图前用par(family="sans")或设置中文字体文件。多数时候我更推荐直接使用英文标签,便于国际期刊或报告使用,中文说明放在正文或图注里。
5.2 效应切片图与累积效应曲线
三维图和等高线图展示了全貌,但回答具体的政策问题时还需要切片图。比如当PM10浓度达到100μg/m³时,风险在滞后0天、1天、2天分别增加了多少,这个就是固定暴露水平沿滞后维度切一刀得到的曲线。反过来,固定某个滞后天数,比如滞后1天,看风险随污染物浓度的变化趋势,这是最容易写进报告里的一类图。
plot(pred, var = 100, type = "p", main = "PM10=100μg/m³ 时的滞后效应", xlab = "滞后天数(天)", ylab = "RR") plot(pred, lag = 1, type = "p", main = "滞后1天的暴露反应曲线", xlab = "PM10 (μg/m³)", ylab = "RR")累计效应曲线是我每次项目必出的图。它在单一暴露水平下把从滞后0天到最大滞后日的全部风险累加起来,反映一次高污染过程对健康的总负担。这个指标对空气质量评估最有价值,因为它把时间维度上的分散效应压缩成了一个总量指标。代码中设置cumul=TRUE后,仍然通过plot(pred)调用,只是加一个cumul参数。
plot(pred, cumul = TRUE, main = "PM10累积效应曲线", xlab = "PM10 (μg/m³)", ylab = "累积RR")5.3 用ggplot2定制专业图表
自带绘图函数胜在快速,但出图风格比较基础,想放进报告或者期刊还得做进一步加工。ggplot2的优势是可以对矩阵数据进行重新组织,再用更现代的图层语法进行绘制。先通过reshape2包把pred对象里的矩阵转换成数据框,再映射到geom_tile或geom_contour图层。
library(ggplot2) library(reshape2) df_pred <- melt(pred$matRRfit, varnames = c("pm10", "lag"), value.name = "RR") df_low <- melt(pred$matRRlow, varnames = c("pm10", "lag"), value.name = "low") df_high <- melt(pred$matRRhigh, varnames = c("pm10", "lag"), value.name = "high") df_plot <- merge(df_pred, df_low, by = c("pm10", "lag")) df_plot <- merge(df_plot, df_high, by = c("pm10", "lag")) ggplot(df_plot, aes(x = pm10, y = lag, fill = RR)) + geom_tile() + scale_fill_gradient2(low = "blue", mid = "white", high = "red", midpoint = 1) + labs(x = "PM10 (μg/m³)", y = "滞后天数(天)", fill = "RR") + theme_minimal()这种热图看起来比自带等高线图更接近发表级别,颜色变化直观表达了效应强度。如果担心热图对局部结构表达不够精细,可以叠加stat_contour图层,把等值线画在色块上。三维交互图还能用plotly方案实现,一句plot_ly把矩阵转换成surface类型即可,适合在演示环境里拖动查看效应面的峰值位置。可视化在目的一开始很清晰,快速探索用自带函数,展示交流用ggplot和plotly,避免在初探阶段花太多时间去调图。
6. 结果解读与政策启示
6.1 项目结果如何解读
模型跑完并出图后,最关键的一步是从结果矩阵中提取出三个核心信息:效应方向、峰值滞后天数、累积风险。效应方向很容易判断,去看最低暴露水平和最高暴露水平之间的相对风险变化,如果整体趋势向上,说明污染物与健康风险存在正向关联。峰值滞后天数是看固定某个中等偏高暴露水平如第75百分位浓度时,不同滞后天上的相对风险在什么时候达到最大值,PM10项目里常见峰值出现在滞后0到2天,随后逐步减弱,持续到一周左右。
累积效应则需要综合整条滞后曲线来判断,如果多个滞后日相对风险都大于1,那么累积效应会比任何单日效应都大。这也是DLNM在公共卫生政策上比简单当天效应分析更有价值的原因,它能够将整个暴露过程的总健康负担量化出来。比如单看滞后0天的RR只有1.01,但累计14天后的累积RR可能到了1.06,这在宏观人群层面就是一个不容忽视的信号。
解读过程中还有一个常被忽略的点是置信区间。某些滞后日上效应很显著,但到了滞后10天以后置信区间可能宽得离谱,这种结果不能过度解读。报告里面最好同时展示点估计和置信区间,用区间宽度自然反映该滞后阶段数据支撑力不足的现实。
6.2 对空气污染管理与公共卫生的启示
从动态效应曲面可以得出几个对管理和预警有实际意义的启示。第一个是滞后结构提示了预警的窗口期,如果峰值效应出现在滞后1到2天,那么高污染事件发生后,医疗系统要在未来两三天内做好应对准备,而不是等高污染结束才安排资源。当前很多城市空气重污染预警更多关注污染本身的持续时间和浓度水平,较少把健康滞后效应纳入调度考量,DLNM可以为这个缺口提供量化依据。
第二个启示是暴露反应曲线的形状直接影响标准制定的思路。如果曲线在低浓度段仍然保持上升且没有明显阈值,说明低浓度污染也存在健康风险,那么“污染浓度低于某标准就是安全的”这种假设就不成立,防控思路应更倾向于持续减排。如果曲线在某个浓度开始变得非常陡峭,那这个拐点附近就是设置预警启动浓度的重要参考。
第三个启示是针对脆弱人群的防护。时间序列数据无法直接识别个体特征,但分层分析比如按年龄、按死因分层可以间接反映不同群体的敏感性差异。如果老年人心血管死亡的滞后效应明显强于全人群,那么预警信息就应当特别强调对这部分人群的防护。政策在落地时需要结合本地人口结构和医疗资源状况来调整响应级别。
6.3 多城市扩展分析机会
单城市DLNM能够很好地刻画本地污染物健康效应,但政策制定者往往需要更大的证据范围。一个非常自然的扩展方向是把多个城市的DLNM结果汇总进行多变量meta分析,R语言里有mvmeta包可以完成这个任务。基本流程是在每个城市分别用相同的交叉基参数拟合模型,提取累计暴露反应关系的系数和协方差矩阵,然后将这些结果放进mvmeta做随机效应meta分析,得到多城市平均效应以及城市间异质性。
多城市分析能够揭示一个重要问题,即不同城市之间的效应差异到底有多大。如果异质性很高,那说明一刀切的空气质量标准可能存在问题,不同城市需要考虑本地污染组成、人口结构和气候条件进行差别化调整。这也是DLNM方法从学术研究走向政策支撑的关键一步。
6.4 模型局限与应用边界
方法再好也要清楚它的边界。时间序列DLNM本质上是生态学设计,分析单位是人群和日期,不是个体,因此不能直接推导个体因果效应。结果只能说明在某一天的人群中,较高污染浓度与较高健康风险之间存在统计学关联,无法排除生态偏倚,也不能回答某个人因为暴露而增加了多少风险。这一点在对外沟通时必须反复强调,不然很容易被误解为个体风险预测工具。
模型本身对数据质量也非常敏感,一方面它要求暴露和结局数据在时间上精确匹配,另一方面,如果城市里监测站点很少,用全市平均浓度代表个体暴露水平就会引入测量误差,而这种误差通常使效应估计趋向于零,也就是让真实效应被低估。空气质量监测网络稀疏的地区,做出来的结果哪怕很显著,也需要谨慎报告。
7. 常见问题与实战避坑
7.1 拟合不收敛和过度离散怎么办
时间序列健康数据很少完全服从泊松分布,死亡数或急诊量在不同日期间波动很大,方差经常明显大于均值。如果使用标准泊松分布,模型本身不一定报错,但标准误会被低估,置信区间会窄得可疑,统计显著性会被夸大。最简单的解决方案就是用family=quasipoisson来拟合,它会自动估计离散参数并调整标准误,这点在dlnm的几乎所有标准示例里都默认使用,实际项目中我不会为了省事去改成普通泊松。
如果使用拟泊松后模型仍出现不收敛的警告,通常要检查两方面。一是是否存在完全分离现象,也就是某个协变量组合下结局计数全是0或全是非0,这种情况在样条自由度过高时更容易出现。二是日期序列中是否存在大量缺失值,导致交叉基矩阵出现全零行,解决方法是对时间趋势的自由度做减法或者插值填补缺失日期。每天数千条数据的大样本中,收敛警告一般不常见。
7.2 自由度与滞后长度怎么选才不会翻车
自由度选择是我见过最多人踩坑的地方。暴露维度的自由度如果给太高,比如df=8以上,低浓度段的效应估计会被个别极端高浓度日拉出虚假的波浪结构,出现若干个小峰小谷,看起来很有新意,实际上就是噪声。一个简单的判断办法是看置信区间宽度,在浓度超过第95百分位的范围内,如果置信区间突然膨胀,那说明曲线形状在数据稀疏区被强行拟合,应当削减自由度。
滞后长度的选择建议先不设太长,正常空气污染项目从lag=14开始试,观察曲线在最后几天是否已经回落到接近1并且置信区间开始大幅变宽。如果是,说明14天足够覆盖效应窗口,不需要继续延长。温度相关项目则要放宽到21天以上,因为热效应和冷效应的生物学延迟比空气污染更持久。对同一份数据做几组参数设定并比较核心结论是否变化,这件事不能省。如果不同参数下峰值滞后天数和累积效应方向基本一致,结论才真正站得住。
7.3 可视化中的高频问题
出图阶段遇到最多的问题集中在三个地方。第一个是中文乱码,这与R绘图设备默认字体有关,Windows下用quartz或windowsFonts注册中文字体,Linux下需要安装中文字体文件并指定family。考虑到多数论文和报告需要英文图表,最简单的方法就是图中全部用英文标签,中文解释留在图注或正文中。
第二个问题是预测点的覆盖范围不够导致曲面缺角。crosspred中设置的at范围如果比数据实际范围窄,三维图会在高浓度区域出现空白平面,轮廓断裂。稳妥的做法是先看summary(pred)确认预测浓度范围,必要时把at的最大值设置到数据的98或99百分位数,而不必取到绝对最大值。第三个问题是当置信区间画在三维图上时,范围太宽会把主效应面遮蔽,三维图因此一般只画点估计,置信区间信息放在切片图上呈现。
7.4 快速排查清单
我在完成一个DLNM项目后会按固定顺序做一遍检查。首先确认拉格和交叉基的参数都记录在脚本头部,保证结果可复现;然后例行检查glm中离散参数是否明显大于1.2,是的话说明过度离散存在,拟泊松是正确选择;再查看交叉效应面与滞后曲线的形状是否符合专业常识,如果出现完全不符合生理学认知的剧烈振荡,优先怀疑自由度设定过高;最后检查几个关键浓度点和滞后日上的置信区间宽度,太宽的结果不写进核心结论。这套检查只需要十分钟,但能避免至少一半的返工。
8. 一些个人的实战体会
我真正把DLNM用顺手,是在连续跑过几个城市的数据之后。初期总想在一次模型里把所有因素都做到最复杂,空气质量、温度、湿度、季节、星期全都上样条,自由度能高就高,结果模型确实收敛了,但效应曲线变得难以解释,有些滞后日的相对风险甚至出现反向保护效应,这种结果在机制上很难自圆其说。后期我逐步意识到,复杂模型的价值应该体现在回答具体问题上,而不是让方法本身变成主角。参数尽量简化,在合理范围内做敏感性分析,然后把精力放在结果是否稳定、结论是否可解释上,这才是实用的分析思路。
如果你刚接触DLNM,建议先用自带数据把完整流程跑通,再换自己的数据,最后再尝试扩展多城市meta分析。不要急着一次做完所有事情,先把一个结果稳定的模型和一张能讲清楚故事的图做出来,后面的衍生分析自然会顺很多。