3. 队列定义要素的SQL表达

队列定义不是一句“把符合条件的患者查出来”,而是把临床问题翻译成一组可执行、可复现、可审计的SQL条件。一个完整的队列定义通常由五个要素构成:入组标准(谁可以进来)、排除标准(谁必须剔除)、索引日期(以哪一天作为时间原点)、基线期(索引日期之前用于刻画患者状态的观察区间)、随访窗口(索引日期之后用于观察结局的时间区间)。这五个要素在SQL中分别对应不同的条件类型和时间逻辑,混在一起写会导致口径不清、结果不可复现。

一、索引日期:整个队列的时间原点

索引日期(index date)是队列中每位患者的“第0天”,所有基线期和随访窗口都相对它计算。索引日期必须由数据中的真实事件确定,常见选择包括首次确诊日期、首次用药日期、首次住院日期或首次满足入组条件的日期。在SQL中,索引日期通常通过聚合函数取得:

-- 以首次确诊日期作为索引日期
SELECT patient_id,
       MIN(diagnosis_date) AS index_date
FROM diagnosis
WHERE icd_code LIKE 'E11%'      -- 2型糖尿病
GROUP BY patient_id;

这里的关键是MIN():它保证每位患者只取最早一次事件,避免同一患者因多次就诊产生多条索引记录。如果索引日期依赖多个条件的先后关系(例如“确诊后首次使用某药”),则需要先用窗口函数或自连接确定事件顺序,再取满足顺序的那一天。

二、入组标准与排除标准:集合的加与减

入组标准是“必须满足”的条件集合,在SQL中表现为多个WHERE或EXISTS条件的交集;排除标准是“一旦满足即剔除”的条件集合,表现为NOT EXISTS或反向条件。二者在逻辑上是“先取交集、再做差集”的关系。例如:

-- 入组:年龄≥18岁,且有2型糖尿病确诊
-- 排除:既往有1型糖尿病、妊娠糖尿病或终末期肾病
SELECT d.patient_id, MIN(d.diagnosis_date) AS index_date
FROM diagnosis d
JOIN patient p ON p.patient_id = d.patient_id
WHERE d.icd_code LIKE 'E11%'
  AND p.birth_date <= DATE_SUB(d.diagnosis_date, INTERVAL 18 YEAR)
  AND NOT EXISTS (
      SELECT 1 FROM diagnosis x
      WHERE x.patient_id = d.patient_id
        AND x.icd_code IN ('E10%','O24%','N18%')
  )
GROUP BY d.patient_id;

需要特别注意的是排除标准的时间范围。如果排除条件是“既往病史”,则必须在索引日期之前判断;如果排除条件是“随访期间发生的事件”,则属于结局或删失逻辑,不能混入基线排除。把排除条件的时间边界写错,是队列构建中最常见的口径错误之一。

三、基线期:索引日期之前的观察窗口

基线期用于提取患者进入队列前的特征,如合并症、既往用药、实验室指标。它的时间逻辑是“索引日期之前的一段固定长度区间”,通常写作[index_date - N天, index_date),左闭右开,避免把索引当天的信息算入基线。例如取索引前365天内的合并症:

-- 基线期:索引日期前365天内的合并症
SELECT c.patient_id, c.icd_code
FROM comorbidity c
JOIN cohort co ON co.patient_id = c.patient_id
WHERE c.diagnosis_date >= DATE_SUB(co.index_date, INTERVAL 365 DAY)
  AND c.diagnosis_date <  co.index_date;

基线期长度必须统一,否则不同患者的基线特征不可比。常见选择是180天、365天或“有记录以来的全部历史”,但后者会引入随访时长偏倚,需谨慎使用。

四、随访窗口:索引日期之后的观察区间

随访窗口定义从索引日期开始、到结局事件、死亡、失访或研究截止日期为止的时间区间。在SQL中,随访窗口通常不直接写成固定天数,而是通过“事件日期与索引日期之差”来计算随访时长,并用CASE WHEN判断结局是否发生:

-- 随访窗口:索引后最长730天,观察是否发生心梗
SELECT co.patient_id, co.index_date,
       MIN(e.event_date) AS event_date,
       CASE WHEN MIN(e.event_date) IS NOT NULL
            THEN DATEDIFF(MIN(e.event_date), co.index_date)
            ELSE DATEDIFF('2024-12-31', co.index_date)
       END AS followup_days,
       CASE WHEN MIN(e.event_date) IS NOT NULL THEN 1 ELSE 0 END AS event_flag
FROM cohort co
LEFT JOIN event e
  ON e.patient_id = co.patient_id
 AND e.event_date >= co.index_date
 AND e.event_date <  DATE_ADD(co.index_date, INTERVAL 730 DAY)
GROUP BY co.patient_id, co.index_date;

这里用LEFT JOIN保证没有事件的患者也保留在队列中,event_flag区分事件发生与否,followup_days同时承担了随访时长和删失时间的表达。若研究截止日期早于730天,则应以截止日期为准,避免把不存在的观察时间算作随访。

队列定义五要素的时间轴与SQL条件映射 索引日期 index_date MIN(事件日期) 基线期 [index-365d, index) 合并症 / 既往用药 / 实验室 随访窗口 [index, index+730d) 结局事件 / 删失 / 失访 入组标准 WHERE + EXISTS(交集) 排除标准 NOT EXISTS(差集) 结局判定 LEFT JOIN + CASE WHEN 时间逻辑要点: ① 基线期左闭右开,不含索引当天;② 随访窗口从索引日期起算,遇事件/死亡/截止即停; ③ 排除标准须明确时间边界(既往 vs 随访期间),避免与结局逻辑混淆。

五、时间逻辑的常见陷阱

第一,边界开闭不一致。基线期若写成<= index_date,会把索引当天的诊断算入基线,造成“用未来信息预测过去”。第二,索引日期与入组条件脱节。若索引日期取首次确诊,而入组条件却用任意一次确诊,两者可能不是同一天,导致基线期错位。第三,随访窗口未处理竞争风险。死亡、失访、研究截止都会提前终止随访,若只用固定730天,会把已死亡患者的“空随访”算作无事件。第四,多次事件未去重。同一患者多次住院,若不加MIN()或ROW_NUMBER(),会产生重复队列记录。

建议把队列定义写成“索引日期子查询 + 入组子查询 + 排除子查询 + 基线子查询 + 随访子查询”的分层结构,每一层单独验证人数,最后再合并。这样任何一层口径变化都能被快速定位。