경제학과 역학이 만나는 곳(2)

조건부 포아송 회귀와 SCCS 데이터 구조

연구논문
Author

Sungjun Park

Published

2026-06-03

주의

초안입니다. SCCS의 조건부 우도 전개, 반복사건 확장, event-dependent exposure 문제는 추가 정리가 필요합니다.

1 개요

지난 포스트에서는 FE(fixed effects)와 SCCS(self-controlled case series)가 각 개인을 자기 자신의 대조군으로 사용한다는 아이디어를 공유한다고 정리했다.

이번 포스트에서는 그 아이디어가 실제 추정식과 데이터 구조에서 어떻게 구현되는지 정리한다. 핵심은 세 가지다.

  • 분석 단위는 사람이 아니라 개인-시간구간(person-time interval)이다.
  • 개인별로 고정된 위험도 \(\alpha_i\)는 조건부 포아송 우도에서 제거된다.
  • 남는 비교는 같은 사람 내부에서의 노출 위험구간과 비노출 구간의 사건 발생률 차이다.

2 SCCS의 기본 모형

개인 \(i\)의 시간구간 \(t\)에서 사건 발생률을 \(\lambda_{it}\)라고 하자. 노출 여부는 \(x_{it}\), 연령대나 calendar time처럼 시간에 따라 바뀌는 보정변수는 \(z_{it}\)로 둔다.

\[ \lambda_{it} = \exp(\alpha_i + \beta x_{it} + \gamma z_{it}) \]

여기서 \(\alpha_i\)는 개인의 고정된 위험도다. 성별, 유전적 요인, 장기적인 건강상태처럼 관찰되지 않았더라도 시간에 따라 변하지 않는 요인이 이 안에 들어간다.

포아송 평균은 관찰시간 \(\Delta_{it}\)를 반영해 다음처럼 쓸 수 있다.

\[ \mu_{it} = \Delta_{it}\exp(\alpha_i + \beta x_{it} + \gamma z_{it}) \]

SCCS는 개인별 전체 사건 수에 조건부로 우도를 만든다. 단순화를 위해 개인 \(i\)에게 사건이 한 번 발생했다고 하면, 그 사건이 특정 구간 \(t\)에 발생할 조건부 확률은 다음과 같다.

\[ \Pr(T_i=t \mid \sum_s y_{is}=1) = \frac{\Delta_{it}\exp(\alpha_i + \beta x_{it} + \gamma z_{it})} {\sum_s \Delta_{is}\exp(\alpha_i + \beta x_{is} + \gamma z_{is})} \]

분자와 분모에 모두 \(\exp(\alpha_i)\)가 들어가므로 약분된다.

\[ \Pr(T_i=t \mid \sum_s y_{is}=1) = \frac{\Delta_{it}\exp(\beta x_{it} + \gamma z_{it})} {\sum_s \Delta_{is}\exp(\beta x_{is} + \gamma z_{is})} \]

이게 SCCS의 핵심이다. 개인 간 비교가 아니라 개인 내부의 시간구간 비교만 남기 때문에, 시간에 따라 변하지 않는 교란요인은 추정식에서 사라진다.

3 데이터 구조

SCCS 자료는 wide 형태보다 long 형태가 자연스럽다. 한 사람의 관찰기간을 노출 시작, 노출 종료, 사건 발생 시점, 연령대 변화 시점 등에 따라 잘라서 여러 행으로 만든다.

library(data.table)
library(ggplot2)

set.seed(20260603)

# 개인 단위 가상자료
# - 모든 사람은 관찰기간 중 사건을 1회 경험한 case로 가정
# - 노출기간은 28일
# - 예시를 명확히 보이기 위해 노출기간의 사건 발생확률을 약간 높게 설정
n_id <- 300

person <- data.table(
  id = 1:n_id,
  obs_start = 0,
  obs_end = 365
)

person[, exposure_start := sample(30:300, .N, replace = TRUE)]
person[, exposure_end := pmin(exposure_start + 28, obs_end)]

person[
  ,
  event_day := {
    days <- 1:365
    event_weight <- ifelse(
      days >= exposure_start & days <= exposure_end,
      2.2,
      1
    )
    sample(days, 1, prob = event_weight)
  },
  by = id
]

# 개인별 관찰기간을 노출 시작/종료 시점으로 분할
DT <- person[
  ,
  {
    cut_points <- sort(unique(c(
      obs_start,
      exposure_start,
      exposure_end,
      obs_end
    )))

    data.table(
      tstart = head(cut_points, -1),
      tend = tail(cut_points, -1)
    )
  },
  by = .(id, obs_start, obs_end, exposure_start, exposure_end, event_day)
]

DT[, person_time := tend - tstart]
DT <- DT[person_time > 0]

# 해당 구간이 노출 위험구간인지 표시
DT[, exposed := as.integer(tstart < exposure_end & tend > exposure_start)]

# 사건일이 해당 구간에 포함되는지 표시
DT[, event := as.integer(event_day > tstart & event_day <= tend)]

# 시간에 따라 변하는 보정변수 예시
# 실제 연구에서는 연령대, calendar month, season 등을 넣는다.
DT[
  ,
  age_band := cut(
    tstart,
    breaks = c(0, 90, 180, 270, 365),
    include.lowest = TRUE,
    labels = c("0-90", "91-180", "181-270", "271-365")
  )
]

DT[, .(id, tstart, tend, person_time, exposed, event, age_band)][1:10]
       id tstart  tend person_time exposed event age_band
    <int>  <num> <num>       <num>   <int> <int>   <fctr>
 1:     1      0   284         284       0     1     0-90
 2:     1    284   312          28       1     0  271-365
 3:     1    312   365          53       0     0  271-365
 4:     2      0   232         232       0     1     0-90
 5:     2    232   260          28       1     0  181-270
 6:     2    260   365         105       0     0  181-270
 7:     3      0   155         155       0     1     0-90
 8:     3    155   183          28       1     0   91-180
 9:     3    183   365         182       0     0  181-270
10:     4      0   152         152       0     1     0-90

4 발생률 확인

모형을 넣기 전에 노출구간과 비노출구간의 조발생률을 먼저 확인한다. 이 값은 아직 개인 고정효과와 시간 효과를 정식으로 통제한 추정치는 아니지만, 자료 구조가 의도대로 만들어졌는지 확인하는 데 유용하다.

rate_dt <- DT[
  ,
  .(
    events = sum(event),
    person_time = sum(person_time)
  ),
  by = exposed
]

rate_dt[, rate_1000_days := events / person_time * 1000]
rate_dt[, exposure_group := ifelse(exposed == 1, "노출 위험구간", "비노출 구간")]

ggplot(rate_dt, aes(x = exposure_group, y = rate_1000_days)) +
  geom_col(fill = "#0072B2", width = 0.55) +
  geom_text(
    aes(label = round(rate_1000_days, 2)),
    vjust = -0.6,
    size = 4
  ) +
  labs(x = NULL, y = "사건 발생률(1,000 person-days)") +
  theme_classic()

5 고정효과 포아송 회귀로 구현하기

작은 예시 자료에서는 개인 고정효과를 factor(id)로 직접 넣어도 된다. 실제 대규모 자료에서는 조건부 포아송 회귀 또는 고정효과 포아송 회귀 전용 구현을 쓰는 편이 낫다.

sccs_fit <- glm(
  event ~ exposed + age_band + factor(id) + offset(log(person_time)),
  family = poisson(),
  data = DT
)

beta <- coef(sccs_fit)["exposed"]
se <- sqrt(diag(vcov(sccs_fit)))["exposed"]

result <- data.table(
  term = "노출 위험구간",
  irr = exp(beta),
  lcl = exp(beta - 1.96 * se),
  ucl = exp(beta + 1.96 * se)
)

result
            term      irr      lcl      ucl
          <char>    <num>    <num>    <num>
1: 노출 위험구간 1.744522 1.211748 2.511541

여기서 exp(beta)는 incidence rate ratio(IRR)다. IRR이 1보다 크면, 같은 개인 안에서 비노출 구간보다 노출 위험구간의 사건 발생률이 높다는 뜻이다.

중요한 점은 이 추정치가 “노출군 vs 비노출군” 비교가 아니라는 것이다. SCCS의 비교는 같은 사람 내부에서 이루어진다. 따라서 개인 간에 고정된 차이, 예를 들어 유전적 취약성이나 장기적인 건강상태 차이는 모형에서 제거된다.

6 SCCS가 깨지는 지점

SCCS가 항상 좋은 것은 아니다. 자기 자신을 대조군으로 쓰는 장점은 강하지만, 그만큼 설계 가정이 분명하다.

  • 사건이 노출 이후의 추적 가능성이나 관찰기간을 바꾸면 문제가 생긴다.
  • 사건 발생이 이후 노출 가능성을 바꾸면 event-dependent exposure 문제가 생긴다.
  • 연령, 계절, calendar time처럼 시간에 따라 변하는 위험요인은 별도로 보정해야 한다.
  • 사건이 너무 잦거나 사건 간 독립성이 약하면 반복사건 모형을 더 신중히 다뤄야 한다.
  • 노출 위험구간의 길이와 washout 기간은 임상적 근거로 정해야 한다.

즉, SCCS는 비관측 시불변 교란을 강하게 제거하지만, 시간에 따라 변하는 교란과 사건-노출 순서 문제까지 자동으로 해결하지는 않는다.

7 마치며

SCCS는 FE 모델의 역학 버전이라고 말할 수 있다. 하지만 단순히 개인 더미를 넣는 회귀분석으로만 이해하면 부족하다. 핵심은 먼저 관찰기간을 올바르게 자르고, 노출 위험구간을 정의하고, 사건이 그 구간 중 어디에서 발생했는지 비교하는 설계에 있다.

다음에 보완할 내용은 두 가지다.

  • 실제 건강보험 청구자료에서 처방일, 투약기간, 사건일을 이용해 SCCS long data를 만드는 절차
  • SCCS에서 event-dependent exposure와 event-dependent observation period를 점검하는 방법