1. 项目概述与核心思路
做纵向数据分析的人,十有八九都会遇到这样一个问题:个体随着时间的变化轨迹,到底能不能分类?传统的混合效应模型会给出一个“平均轨迹加上随机偏移”,但如果你关心的是“人群是否存在不同发展模式”,普通线性混合模型就不太够用了。这时就需要用到组轨迹模型,也就是标题里的GBTM(Group-Based Trajectory Models)。这个模型最早是Nagin提出的,在犯罪学、心理学、医学等领域用得非常多,比如青少年攻击行为的发展轨迹、慢性病患者的用药依从性变化、认知衰退的异质性路径等等。
trajeR包就是R语言里专做GBTM分析的工具。第一次接触这个包的人,多半是被“trajeR”这个名字吸引进来的——它对标的是SAS里的PROC TRAJ,如果你以前用过SAS那套流程,转过来会觉得很亲切。但和SAS的闭源商业环境不同,trajeR直接在R生态里跑,能和ggplot2、dplyr这些常用包无缝配合,出图、清洗数据、做后续统计分析都在同一个工作流里完成。
这篇博文适合谁?正在做纵向数据分析、想识别异质性发展轨迹的研究者,尤其是手里已经有面板数据或者随访数据,但不知道从哪下手的人。我会从模型原理讲到R语言实操,再到结果解读和常见坑,尽量把路径讲完整。
trajeR包的优势在于它实现了Nagin提出的完整GBTM框架,包括Censored Normal模型、Zero-Inflated Poisson模型和Logit模型三种分布假设,覆盖了连续型、计数型和二元型三种数据类型。它不仅能拟合模型,还提供了模型比较指标(BIC)、后验概率计算、分类诊断等功能,等于把整个分析闭环都做完了。
1.1 核心需求解析
要理解GBTM,首先得跳出“回归一个总体平均轨迹”的思维。GBTM的核心假设是:总体由若干潜在子群体组成,每个子群体有一条属于自己的平均轨迹,个体在子群体内部围绕该平均轨迹随机波动。它不要求预先知道个体属于哪个组,而是通过模型估计出每个个体属于各组的后验概率,再把个体分配到概率最高的那一组。
这和我们熟悉的K-means聚类有本质区别。K-means是先计算个体间的距离再聚类,而GBTM是基于每个个体完整的纵向轨迹来建模,考虑了时间结构。所以它更适合处理“同一个体多次测量”的数据,而不是横截面数据。
trajeR包的基本流程可以概括为:准备数据、指定模型类型、设定组数和多项式阶数、拟合、比较BIC、检查诊断指标、输出结果。看起来不复杂,但每一步都有很多细节坑,比如数据格式不对、模型不收敛、组数选多了导致某组样本量太少等等。我在后面的章节会把这些坑一个个讲清楚。
2. 环境搭建与数据准备
2.1 R环境与trajeR包安装
在跑trajeR之前,先把R环境准备好。这篇内容假设你已经安装了R语言和RStudio,如果还没装,先去官网下载R和RStudio,版本建议R 4.0以上。trajeR包对R版本有一定要求,我自己在R 4.2和R 4.3上都跑过,都没问题。
trajeR包目前没有发布到CRAN官方仓库,需要从GitHub安装,这一步是新手最容易卡住的地方:
# 先安装devtools包 install.packages("devtools") # 从GitHub安装trajeR devtools::install_github("hadjiakbarh/trajeR")安装过程中会自动下载依赖包,包括Rcpp、MASS、ggplot2这些。如果在Windows上编译报错,多半是缺少Rtools,去Rtools官网下载对应版本装上就行。注意Rtools版本必须和你的R版本匹配,这一点非常容易踩坑。
2.2 数据格式与结构要求
trajeR对数据格式有比较严格的要求,这一节说清楚,能帮你省半天的时间。
GBTM分析需要纵向数据,每个个体在多个时间点有观测值。trajeR要求的输入格式是宽格式,也就是说每一行代表一个个体,每一列代表一个时间点的测量值。举例来说,如果你有100个个体,每个个体在5个时间点有测量,那么数据框应该有100行和至少5列测量值。
# 模拟一个适合trajeR的宽格式数据集 set.seed(123) n <- 300 # 300个个体 time_points <- 5 # 每个个体5次测量 # 生成时间变量 time <- seq(1, 5, length.out = time_points) # 真实分组:Group 1: 低水平平稳, Group 2: 中水平上升, Group 3: 高水平下降 group <- sample(1:3, n, replace = TRUE, prob = c(0.4, 0.35, 0.25)) data <- data.frame(id = 1:n) # 为每个组生成不同的轨迹 for (t in 1:time_points) { col_name <- paste0("Y", t) data[[col_name]] <- NA for (i in 1:n) { if (group[i] == 1) { data[[col_name]][i] <- 2 + rnorm(1, sd = 0.5) } else if (group[i] == 2) { data[[col_name]][i] <- 2 + 1.2 * time[t] + rnorm(1, sd = 0.5) } else { data[[col_name]][i] <- 8 - 1.5 * time[t] + rnorm(1, sd = 0.5) } } } head(data)数据里有时间点,但时间变量不需要放在测量值列里。trajeR通过一个独立的参数来指定时间变量向量,后面实操部分会讲到。还有一点,trajeR不支持时间点不规则的个体,也就是说每个个体必须都有完整的时间点测量,如果你只有部分时间点的数据,需要在拟合之前在数据框里把缺失的时间点设置为NA。
2.3 缺失值处理建议
GBTM对缺失值的处理并不复杂,但有一个原则:如果某个个体在某一个时间点缺失,它在那一列依然是NA,但在拟合时trajeR会删除该个体在那一列的记录。如果缺失比例过高,我建议在拟合前就直接删除这些个体,否则模型会基于很少的记录来估计那条轨迹,很不稳定。
更稳妥的做法是,在建模前先做一次缺失值数据结构分析:
# 计算每个个体的缺失情况 data$miss_count <- rowSums(is.na(data[, paste0("Y", 1:5)])) table(data$miss_count) # 如果某个体缺失超过2个时间点,建议删除 data_clean <- data[data$miss_count <= 2, ]这样处理之后,剩下的数据再去跑trajeR,模型稳定性和结果可解释性都会好很多。
3. 实操核心——拟合组轨迹模型
3.1 trajeR函数基本结构
trajeR的核心函数就叫trajeR,语法格式如下:
trajeR(Y, A, deg, Model, ng, ...)其中:
- Y:宽格式的测量值数据框(不含时间变量)
- A:时间变量向量
- deg:每组轨迹的多项式阶数向量
- Model:模型类型,可选"CNORM"(正态截断)、"ZIP"(零膨胀泊松)、"LOGIT"(二分类)
- ng:组的数量
初次接触时最容易犯的错误是deg参数。比如说你打算拟合3组,每组用二次多项式(抛物线),那么deg应该写成c(2,2,2),而不是一个单独的2。trajeR会把这个参数和ng一一对应起来,如果长度不匹配直接报错。
3.2 选择模型类型
选择Model参数是整个分析中最关键的决策之一,它直接决定了你对数据的假设。
如果你的测量指标是连续性分数,比如量表得分、体重、血压,用CNORM(Censored Normal)模型。这个模型假设数据服从正态分布,但考虑到可能存在天花板或地板效应,允许设置阈值。trajeR的CNORM模型可以设置一个最小值和最大值,避免预测值超出合理范围。
如果你的测量指标是计数数据,比如不良事件次数、犯罪次数、住院次数,用ZIP模型。ZIP模型的特别之处在于它把“零”作为一个特殊群体来处理,因为计数数据里常出现大量零值——比如多数人没有犯罪记录,只有少数人有,这时候普通的泊松模型会低估零的比例。
如果你关心的是某个二分类结果,比如是否患病、是否就业、是否抑郁,用Logit模型。它拟合的是事件发生的概率,每个组的轨迹表示该组随时间变化的发生概率。
从我的使用经验来看,CNORM是大多数人的选择,因为很多人用的是连续性结局指标。我在这篇博文里以CNORM模型为主来演示,但ZIP和Logit的调用方式几乎完全相同,只是结果解释时注意概率和期望值的差异。
3.3 多项式阶数选择
deg参数决定了每条轨迹的形态。通常的做法是先试探性拟合一个简单的模型,比如所有组都用一阶(线性),然后逐步提高阶数,用BIC来评估。
# 加载trajeR包 library(trajeR) # 准备数据 Y <- data_clean[, paste0("Y", 1:5)] # 测量值 A <- c(1, 2, 3, 4, 5) # 时间点 # 拟合一个简单的3组模型,所有组线性 model_3g_linear <- trajeR(Y = Y, A = A, deg = c(1,1,1), Model = "CNORM", ng = 3) summary(model_3g_linear)这里的summary输出包括BIC值、每组样本量占比、参数估计等。BIC越低模型越好,但要注意BIC不能无限制地靠增加组数来优化,否则会过拟合。
3.4 组数选择策略
组数(ng)的选择没有绝对正确的答案,需要结合统计指标和专业理论来综合判断。我常用的思路是先设定一个比较宽的搜索范围,比如从2组到5组,每组用一阶或二阶多项式拟合,然后记录每个模型的BIC。
results <- data.frame(ng = 2:5, BIC = NA) for (g in 2:5) { m <- trajeR(Y = Y, A = A, deg = rep(2, g), Model = "CNORM", ng = g) results[results$ng == g, "BIC"] <- m$BIC } print(results)然后比较BIC的变化趋势。BIC通常会随着组数增加而下降,但下降幅度会越来越小。一般的经验法则是选“BIC下降幅度明显变缓”的那个点,也就是所谓的“拐点”。还有一个辅助指标是平均后验概率,每个组的平均后验概率最好都在0.7以上,至少不低于0.6。
我在实际分析中发现,如果某组的人数占比低于5%,这组可能没有实际意义,哪怕BIC显示它应该存在。这种情况在医学研究中很常见——一个极小的亚组在统计上显著,但临床意义存疑。
3.5 实例演示:3组CNORM模型拟合
回到我们的模拟数据,假设我们决定拟合3组、每组二阶多项式的模型:
# 拟合3组模型,每组二次多项式 model_3g <- trajeR(Y = Y, A = A, deg = c(2,2,2), Model = "CNORM", ng = 3) summary(model_3g)summary输出会包含模型的对数似然值、AIC、BIC、每组的参数估计(Intercept、Linear、Quadratic)、每组的样本量等。
trajeR还提供了confint方法,可以查看参数估计的置信区间:
confint(model_3g)这里有一个值得关注的细节:即使你设置了每组二次多项式,有些组的二次项系数也可能不显著。这意味着该组的真实轨迹可能是线性的,过拟合会引入不必要的参数,增加模型复杂度。所以一个常用的策略是:先拟合一个全部为高阶的模型,然后根据显著性检验,把不显著的阶数降下来,再重新拟合比较BIC。
这种做法在trajeR里可以通过手动调整deg向量来完成,是一个很灵活的模型优化方式。
3.6 后验概率与分类
拟合完成后,trajeR会自动计算每个个体属于每个组的后验概率。你可以用postprob方法提取后验概率矩阵:
posterior <- postprob(model_3g) head(posterior)每个个体会被分配到后验概率最高的那一组。你可以把这个分组信息添加到原始数据里,方便后续分析:
data_clean$assigned_group <- posterior$group然后你可以用这个分组变量做后续的交叉分析,比如各组在基线特征上的差异比较,或者和外部结局变量的关联分析。这就是GBTM的灵活之处——它为后续分析提供了一个“潜在分类变量”,这是传统方法做不到的。
4. 结果可视化与输出
4.1 绘制轨迹图
模型拟合完,最重要的一步就是把轨迹画出来。一张好的轨迹图能直观传达“这几组在怎么发展变化”的信息,比任何表格都有说服力。
trajeR提供了一个绘图函数trajPlot:
trajPlot(model_3g, Y = Y, A = A, Model = "CNORM", main = "3-Group Trajectory Model", xlab = "Time", ylab = "Y Value")这个函数会画出三条曲线,分别是每组估计的平均轨迹,并附带95%置信区间。如果你想把观测数据点和轨迹叠放在一起,可以在调用时加上ci=TRUE参数,或者用ggplot2自己画。
4.2 自己用ggplot2画图
trajeR自带的绘图功能够用,但我更推荐自己用ggplot2画,这样图表样式可以完全自定义,配色、字体、尺寸都能和论文或报告的要求匹配。画图前需要先提取每组的预测轨迹:
library(ggplot2) # 提取参数 params <- model_3g$params # params是一个矩阵,每行对应一组,列包括Intercept, Linear, Quadratic等 # 生成预测值 time_seq <- seq(1, 5, length.out = 100) predictions <- data.frame() for (g in 1:3) { pred <- params[g, "Intercept"] + params[g, "Linear"] * time_seq + params[g, "Quadratic"] * time_seq^2 temp <- data.frame(Time = time_seq, Y = pred, Group = as.factor(g)) predictions <- rbind(predictions, temp) } # 绘制轨迹 ggplot(predictions, aes(x = Time, y = Y, color = Group)) + geom_line(size = 1.2) + labs(title = "3-Group Trajectory Model", x = "Time", y = "Predicted Y") + theme_minimal()这里的params提取方式是一个通用思路,实际使用时你需要查看model_3g$params的具体结构来确定列名称。如果结果显示某些组的“Quadratic”为0,那说明该组在优化中被降阶了(如果你在trajeR调用中设置了不同的deg),画图时也会只画线性趋势。
4.3 平均后验概率和组间差异
除了预测轨迹图,还有一个重要的诊断指标叫平均后验概率(Average Posterior Probability,简称AvePP)。每组应该计算该组所有个体的后验概率均值,理想情况下应该在0.7以上。如果某组的AvePP很低,说明这组区分度不够,组间存在大量模糊分类的个体。
# 计算每组的平均后验概率 assignments <- posterior$posterior # 后验概率矩阵 groups <- posterior$group # 分组结果 avepp <- sapply(1:3, function(g) mean(assignments[groups == g, g])) names(avepp) <- paste0("Group ", 1:3) print(avepp)当发现某组AvePP低时,处理方法有几个方向:一是减少组数,让模糊的个体被合并到其他组;二是增加多项式阶数,看是否能通过更灵活的轨迹形状提高区分度;三是检查是否有个体存在极端观测值,考虑是否删除后重新拟合。
4.4 组间分布与后续分析
最后,你可能会想比较各个潜在组在基线特征上的差异,或者评估这些组对远期结局的预测能力。这些分析在获得分组变量后直接进行即可。我这里举一个简单的例子,比较各组的某个基线变量:
# 添加分组标签 data_clean$group_label <- factor(data_clean$assigned_group, labels = c("Low Stable", "Moderate Increase", "High Decrease")) # 比较基线变量(假设有一个基线变量baseline) # data_clean$baseline <- rnorm(nrow(data_clean), mean = 5, sd = 2) # 例如用线性模型看组间差异 lm_model <- lm(baseline ~ group_label, data = data_clean) summary(lm_model)这样整个GBTM分析流程就闭环了:从数据准备到模型拟合再到可视化与后续分析,trajeR都提供了配套工具。
5. 常见问题与排查技巧实录
5.1 安装报错:Rtools问题
这是最常遇到的问题。Windows用户在执行install_github时如果出现“compilation failed”或者“SystemRequirements: C++11”之类的报错,几乎都是Rtools没装或者不匹配。
解决办法是:查看你的R版本,到Rtools官网下载对应版本,在安装时勾选“Add Rtools to PATH”这个选项,然后重启RStudio再装一次。我这里强调一下,R 4.2对应Rtools42,R 4.3对应Rtools43,版本不匹配确实会白折腾一场。
还有一个小技巧,如果GitHub下载实在太慢或者连接不稳定,可以先把仓库打包下载到本地,再用install.packages("trajeR_0.1.0.tar.gz", repos = NULL, type = "source")本地安装。不过需要注意,本地安装依然需要Rtools编译源码。
5.2 模型不收敛或参数异常
使用trajeR时,偶尔会遇到模型迭代不收敛,或者收敛后参数估计出现极端值。这种情况我遇到过几次,原因多半是初始值不当或者组数过多导致参数空间太大。
trajeR允许你手动设定迭代的最大次数,默认值是1000次。如果模型在1000次迭代后还没收敛,可以尝试把这个值调大。可以通过options参数来设置:
model_3g <- trajeR(Y = Y, A = A, deg = c(2,2,2), Model = "CNORM", ng = 3, itermax = 2000)还有一个办法是先拟合一个较简单的模型,比如所有组只取线性(deg = c(1,1,1)),得到参数估计后作为初始值,再拟合更复杂的模型。trajeR的文档中没有直接提供设置初始值的接口,但通过逐步增加复杂度可以缓解这类问题。
还有一种参数异常的情况:某组的样本量占比特别小,比如只占总体的1%~2%。这时候参数估计往往很不稳定,置信区间极宽。我的建议是,如果某组占比过低,减少组数重新拟合,或者考虑把它和其他相似的组合并。
5.3 数据格式错误
trajeR要求Y参数是宽格式数据框,A是时间变量的数值向量。一个常见错误是数据里包含了个体ID列,但没有从Y中剔除。比如:
# 错误示例:Y里包含了ID列 trajeR(Y = data_clean[, c("id", "Y1", "Y2", "Y3", "Y4", "Y5")], A = c(1, 2, 3, 4, 5), deg = c(2,2,2), Model = "CNORM", ng = 3)这样会导致trajeR把ID列也当作一个测量时间点来建模,结果会非常离谱。务必确保Y只包含各时间点的测量值。
还有一类问题出现在A的长度和Y的列数不一致时。如果你有5个时间点,A就必须是长度为5的数值向量。如果时间点之间间隔不均匀,比如第1、第3和第10个月有测量,A应该写成c(1,3,10),trajeR会在多项式拟合时自动处理这个不等距设计。
5.4 组数选择的纠结
这是GBTM分析中最让人头疼的问题。有时4组模型BIC确实比3组低,但4组中的某一组在专业上很难解释。我的态度是:统计指标是参考,领域理论是根本依据。一个在专业上难以解释的组,即使统计指标更优,也不应该强行保留。
我有一次在分析慢性病患者用药依从性时,5组模型的BIC明显优于4组,但第5组只包含了6个人(总样本量800多),而且这6个人的特征非常离散,找不到共同点。最终我选择了4组模型,因为在临床场景里这4组的分类更有指导意义。后来在审稿时,审稿人也比较认可这种做法,认为“组的选择应当具有实质性的可解释性”。
5.5 ZIP模型的零膨胀问题
如果你用的是ZIP模型,要注意trajeR的输出参数里包含零膨胀概率的参数。ZIP模型假设一部分个体的计数是结构性的零值,另一部分个体在一个潜在的泊松过程中随机变化。这个零膨胀概率会随时间变化。
对于ZIP模型,输出的参数有每组轨迹的参数(通常是对数尺度的),还有一个零膨胀概率的参数。解读时需要注意,轨迹曲线画出来的可能是期望计数,而不是某个特定的发生率。如果你的数据中零的比例过高(比如超过50%),ZIP模型可能比重力模型更好,但前提是你对这种“结构性零”有理论上的预期。
6. 实操心得体会
在多个项目里用过trajeR之后,我总结了几条亲测有效的经验。
第一,建模前花时间在数据探索上绝对值得。先看看数据里有多少人完成了全部时间点的测量、每个时间点的分布形态、是否存在明显的天花板或地板效应,这些都会直接影响Model和deg的初始设定。
第二,不要在BIC上做“死磕”。很多刚开始用GBTM的人会陷入一个误区:不断加组、加阶数,直到BIC降到最低。最终得到的是一个过度拟合、无法解释的结果。模型只是工具,文章的核心是领域故事。
第三,如果条件允许,结合多重插补法处理缺失值后再跑trajeR,要比直接listwise deletion稳健得多。我通常用mice包做多重插补,然后在每个插补数据集上分别跑GBTM,再汇总结果。这样能更充分地利用数据信息,得到的组分配也更稳定。
第四,trajeR的输出对象结构不算特别直观,建议在拟合完模型后立刻用str()查看一下对象内部结构,了解哪些元素对应参数估计、哪些对应BIC、哪些对应后验概率。在自动化分析时,这种对对象结构的熟悉会让你省很多时间。
最后再分享一个小技巧:如果论文或报告里需要展示分组后的个体归属概率,不妨画一个“全样本后验概率热图”,把每个个体在每组的后验概率用颜色深浅表示出来。这张图能让读者直观看到分类的清晰度。实现方式很简单,就是用ggplot2的geom_tile绘制这个矩阵。我做了几次之后发现,它比任何文字描述都更有说服力。