自我對照設計
讓每個人當自己的對照,一次消掉所有不隨時間變的個人特徵——包括你沒測量、也不知道存在的那些。SCCS 與 case-crossover 的差別、三個假設各自在什麼時候破掉,以及為什麼「事件會不會影響後續暴露」是這個設計最致命的問題。
這一頁在解決什麼問題
工具變數那一頁處理的是同一個困境——未測量的干擾——但它的代價很高:三個假設裡有兩個無法驗證,而且信賴區間會寬上好幾倍。
自我對照設計(self-controlled design)走的是完全不同的一條路,而且它的想法簡單到近乎粗暴:
如果比較的兩邊是同一個人的兩段時間,那麼所有「不隨時間改變的個人特徵」在比較裡自動消失。
基因、體質、性別、族裔、童年環境、社經地位、慢性共病的底子、就醫習慣——這些東西你測不測得到都無所謂,因為它們在同一個人的兩段時間裡是同一個值,在人內的比較裡直接抵消掉。這不是統計校正,是設計上的消去。
代價換到了別的地方:它只回答「為什麼是現在」,不回答「為什麼是我」;它只適合短暫的暴露配上時點明確的急性事件;而且它有三個假設,其中至少一個在藥物流行病學裡經常被破壞。
兩種錨點:SCCS 與 case-crossover
所有自我對照設計都由三個東西定義:一個錨點(anchor,時間軸上的定位點)、一個焦點窗(focal window,假設風險升高的那段時間)、以及一個或多個對照窗(referent window,用來估「平常的頻率」)。分析一律以個人為分層(條件式 Poisson 或條件式邏輯迴歸)——這一步才是把不隨時間變的特徵消掉的地方。
兩大家族的差別在於錨點釘在哪裡:
| Case-crossover(CCO) | Self-controlled case series(SCCS) | |
|---|---|---|
| 錨點 | 結果事件 | 暴露 |
| 比的是 | 焦點窗與對照窗的暴露頻率 | 焦點窗與對照窗的事件發生率 |
| 問句 | 「事件發生在這一刻,前面剛好有暴露的機會有多大?」 | 「暴露發生了,之後短時間內出事的機率有多高?」 |
| 對照窗放哪 | 事件之前的一或多個時點 | 觀察期內焦點窗以外的全部時間 |
| 方向 | 單向(除非刻意做雙向) | 雙向——觀察期不會因為事件發生而結束 |
| 統計模型 | 條件式邏輯迴歸或 Mantel-Haenszel,估 OR | 條件式 Poisson 迴歸,估 IRR |
| 對長期用藥 | 有已知的向上偏誤(persistent user bias) | 較不受影響,但時間相依干擾的風險上升 |
| 對「事件影響後續暴露」 | 不敏感(對照窗在事件之前) | 敏感,是它的核心假設之一 |
| 對「事件影響觀察期長度」 | 不敏感 | 敏感(結果是死亡時直接破功) |
最後兩列是實務上選哪一個的關鍵:結果的死亡率高、或出事之後醫師會停藥的情況,CCO 比較安全;暴露的使用率隨時間快速改變(例如疫苗剛開打)的情況,SCCS 比較安全。
這一頁的模擬
設定對應到藥物流行病學最典型的問題:一個十四天的療程,與一個急性事件(比方說心律不整住院),真實的發生率比是 3.0。模擬裡刻意放了兩件事:
- 人與人之間的基線事件率差很多(frailty 的標準差是 1.0,也就是最高風險與最低風險的人差好幾倍),而且這個 frailty 完全沒有被測量
- 病得比較重的人比較容易被開這個藥——模擬出來的暴露組與未暴露組,frailty 差了 0.83 個標準差
模擬了 4000 個人、各觀察 365 天,其中 1614 位在期間內用過藥,共發生 4084 次事件。
figures/scripts/B6-05-self-controlled.RSCCS 的條件式概似,寫出來長這樣
SCCS 的核心是一條可以手寫的式子。對一位在觀察期內有 次事件的病人,其中 次落在風險窗裡,他在風險窗待了 天、在對照窗待了 天,那麼在已知他一共出了幾次事的條件下,這些事件落在風險窗的機率給出:
就是發生率比(IRR)。式子裡沒有那個人的基線風險——它在「已知他出了幾次事」這個條件裡被約掉了。這一行代數就是「所有不隨時間變的個人特徵被消掉」的全部內容。
同時它也解釋了兩件事:只有同時有事件又有暴露的人才對估計有貢獻(其他人的貢獻是常數,微分掉了),以及這個設計估不出絕對風險——分母被條件掉了,只剩比值。
library(survival)
set.seed(20260822)
OBS <- 365L; RISK <- 14L; IRR <- 3.0; BASE <- 0.0016
# ── 模擬:frailty 未測量,且同時決定「會不會被開藥」與「事件率」 ──────
simulate_people <- function(n) {
frailty <- rnorm(n)
exposed <- rbinom(n, 1, plogis(-0.5 + 1.0 * frailty)) # 適應症干擾
start <- ifelse(exposed == 1, sample(OBS - RISK, n, TRUE), NA)
rate <- BASE * exp(frailty)
lapply(seq_len(n), function(i) {
inrisk <- rep(FALSE, OBS)
if (exposed[i] == 1) inrisk[start[i]:(start[i] + RISK - 1L)] <- TRUE
days <- which(rbinom(OBS, 1, pmin(rate[i] * ifelse(inrisk, IRR, 1), 1)) == 1)
list(exposed = exposed[i], inrisk = inrisk, eventDays = days,
riskDays = sum(inrisk), controlDays = OBS - sum(inrisk),
events = length(days),
eventsInRisk = if (length(days)) sum(inrisk[days]) else 0L)
})
}
rows <- simulate_people(4000)
# ── SCCS:條件式概似,直接最佳化 ─────────────────────────────────────
# 只有「有事件 且 有暴露」的人進得來——其餘的人在概似裡是常數
use <- Filter(function(r) r$events > 0 && r$exposed == 1, rows)
r_day <- sapply(use, `[[`, "riskDays")
c_day <- sapply(use, `[[`, "controlDays")
n_rsk <- sapply(use, `[[`, "eventsInRisk")
n_all <- sapply(use, `[[`, "events")
nll <- function(b) -sum(n_rsk * b - n_all * log(c_day + exp(b) * r_day))
o <- optim(0, nll, method = "BFGS", hessian = TRUE)
se <- sqrt(1 / o$hessian[1, 1])
exp(c(IRR = o$par, lcl = o$par - 1.96 * se, ucl = o$par + 1.96 * se))
# ── Case-crossover:每一次事件一個分層,條件式邏輯迴歸 ───────────────
lags <- c(30, 60, 90) # 三個對照時點,都在事件之前
recs <- list()
for (k in seq_along(rows)) {
r <- rows[[k]]
for (ev in r$eventDays) {
wins <- c(ev, ev - lags)
if (any(wins < 1)) next
recs[[length(recs) + 1]] <- data.frame(
sid = paste0(k, "_", ev), case = c(1L, rep(0L, length(lags))),
exposed = as.integer(r$inrisk[wins]))
}
}
dd <- do.call(rbind, recs)
summary(clogit(case ~ exposed + strata(sid), data = dd))$conf.int驗證環境:R 4.6.0 + survival 3.8.6(SCCS 的概似函數用 base R 的 optim 直接最佳化)
import numpy as np
from scipy.optimize import minimize
from scipy.special import expit
rng = np.random.default_rng(20260822)
OBS, RISK, IRR, BASE = 365, 14, 3.0, 0.0016
def simulate_people(n):
frailty = rng.normal(size=n)
exposed = rng.binomial(1, expit(-0.5 + 1.0 * frailty))
start = rng.integers(1, OBS - RISK, size=n)
rate = BASE * np.exp(frailty)
out = []
for i in range(n):
inrisk = np.zeros(OBS, bool)
if exposed[i]:
inrisk[start[i]:start[i] + RISK] = True
lam = rate[i] * np.where(inrisk, IRR, 1.0)
days = np.flatnonzero(rng.binomial(1, np.minimum(lam, 1)))
out.append(dict(exposed=exposed[i], inrisk=inrisk, days=days,
r=inrisk.sum(), c=OBS - inrisk.sum(),
n=len(days), nr=int(inrisk[days].sum()) if len(days) else 0))
return out
rows = simulate_people(4000)
use = [r for r in rows if r["n"] > 0 and r["exposed"]]
rd = np.array([r["r"] for r in use]); cd = np.array([r["c"] for r in use])
nr = np.array([r["nr"] for r in use]); na = np.array([r["n"] for r in use])
nll = lambda b: -np.sum(nr * b[0] - na * np.log(cd + np.exp(b[0]) * rd))
fit = minimize(nll, [0.0], method="BFGS")
print(np.exp(fit.x[0]))Python 側用 scipy.optimize.minimize 最佳化同一條負對數概似;case-crossover 可用 statsmodels 的 ConditionalLogit。
五種分析,同一個真值
figures/scripts/B6-05-self-controlled.R| 分析方式 | 估計值 | 95% CI | 相對真值 |
|---|---|---|---|
| 真實發生率比(模擬設定值) | 3.00 | — | — |
| 暴露期人時 vs 其餘人時(跨人比較) | 4.57 | 4.05–5.17 | 被適應症干擾推高 |
| SCCS(人內比較) | 3.01 | 2.66–3.41 | 涵蓋真值 |
| Case-crossover,每一次事件都用 | 2.83 | 2.35–3.41 | 涵蓋真值 |
| Case-crossover,只用第一次事件 | 4.62 | 3.24–6.60 | 在這份模擬裡被高估 |
| SCCS,但事件會終止觀察期 | 3.87 | 3.05–4.91 | 第三個假設被破壞 |
第一列是這一頁的重點:把所有人的暴露期人時放在一起、跟所有人的非暴露期人時比,估出來是 4.57,而真值是 3.00。 偏誤的來源不是算錯,是那 22596 個暴露人天全部來自 frailty 較高的那群人——跨人比較沒有辦法把這件事修好,因為 frailty 不在資料裡。
SCCS 用同一份資料估出 3.01(2.66–3.41)。它只用到 1017 位同時有事件又有暴露的病人、2555 次事件,其中 274 次落在風險窗內。沒有校正任何共變項,也沒有用到任何未暴露的對照組。
SCCS 的三個假設
這個設計買到的東西很大,所以價錢也不便宜。三個假設,逐條看:
假設一:事件必須是「獨立重複發生」的,或是夠罕見
如果一次事件會改變後續事件的機率(例如第一次中風之後,第二次的機率永遠不一樣了),條件式概似的推導就不成立。慣例上的處理是只分析每個人的第一次事件,而這只有在事件夠罕見(研究期間的發生率低於 10%)時才安全。本頁的模擬用的是獨立的 Poisson 過程,所以重複事件全部可以用。
假設二:事件不能影響後續的暴露機率(event-independent exposure)
這是藥物流行病學裡最常被破壞的一條。 病人出事之後,醫師往往就不敢再開那個藥了——於是「事件之後」那段時間的暴露機率被人為壓低,對照窗看起來比實際上更「乾淨」,IRR 被高估。
歷史上的處理是在暴露之前挖一段過渡窗(pre-exposure window)排除掉。近年的研究指出這個修法的效果並不穩定,偏誤大小不只取決於延後多久,還取決於觀察期有多長、暴露多半發生在觀察期的哪一段。擔心這條假設的時候,正確的作法是用專門處理事件相依暴露的 SCCS 延伸版做敏感度分析,而不是加一段過渡窗就當作解決了。
假設三:事件不能影響觀察期的長度(event-independent observation period)
SCCS 之所以是雙向的,靠的就是「觀察期不會因為事件發生而結束」。結果是死亡的時候,這條假設直接破功——人死了,後面的觀察期就沒了。
模擬的最後一列示範了這件事:把資料改成「事件一發生就終止追蹤」,其他都不變,SCCS 估出 3.87(3.05–4.91),比真值 3.00 高了將近三成,而且信賴區間完全沒有涵蓋真值。
Case-crossover 的核心假設
CCO 的假設只有一句話,但它管得很寬:在「暴露沒有效果」這個虛無假設之下,對照窗的暴露機率必須代表焦點窗的暴露機率。
這句話的兩個直接後果:
第一,暴露的使用率不能有明顯的時間趨勢。 如果這個藥的處方量逐年上升,那麼「事件當天」比「三十天前」更可能在用藥——與藥效完全無關,純粹因為時間。估計會被系統性推高。處理方式有兩種:雙向 CCO(對照窗放在事件前後兩側,但這需要多加一條「事件不影響後續暴露」的假設),或是 case-time-control,用一組沒發生事件的人估出「時間趨勢的 OR」再除掉。
第二,長期用藥會產生向上的偏誤。 CCO 只有暴露狀態不一致的人才提供訊息。如果一個藥是天天吃的,那麼唯一會出現「事件當天有吃、三十天前沒吃」的人,就是剛開始吃不久就出事的那些——反過來的組合(事件當天沒吃、三十天前有吃)幾乎不存在,因為沒出事的人不會停藥。這叫 persistent user bias,它是 CCO 在慢性用藥上系統性的問題,SCCS 因為是雙向的所以比較不受影響。
這句「只有暴露狀態不一致的人才提供訊息」在本頁的模擬裡可以直接數出來。上表第三列(每一次事件都用)一共有 3052 個配對組——一次事件一組——其中暴露狀態在焦點窗與對照窗之間真的不一致、因此進得了條件式概似的只有 445 組(14.6%)。第四列(只用第一次事件)是 127 / 1168 組(10.9%)。兩列真正撐起估計的配對組都不到表面組數的六分之一,而讀者只看上表是看不出來的——這正是下面「常見誤用」表裡「沒說明有多少人實際貢獻了訊息」那一條。
這個設計消不掉什麼
自我對照設計消掉的是不隨時間變的東西。所以下面這些它一個也處理不了:
- 時間相依的干擾因子——年齡、季節(流感、氣溫)、疾病本身的自然進程。觀察期越長,這個問題越嚴重。標準作法是把年齡與日曆時間切成區段一起放進條件式模型。
- 暴露與結果的時點記錄不準——焦點窗與對照窗會被劃錯,事件被歸到錯的時段。所以這個設計要求事件的時間點明確可考(急性事件、有精確日期);起病時間模糊的疾病(子宮內膜異位症)或「有需要才吃」的藥(偏頭痛止痛藥)不適合。
- 選擇偏誤——如果落在焦點窗的事件比落在對照窗的更容易被記錄下來(例如打完疫苗後的不適比較容易被通報),關聯會被高估。
- 絕對風險——這個設計本質上估不出來。要換算成可歸因分數或每萬人多幾例,需要外部的分母資訊,而那要非常小心。
什麼時候該想到自我對照設計
四個條件同時成立的時候,它往往是最好的選擇:
- 暴露是短暫的(或者暴露雖然是永久的,但它對結果的影響是短暫的——疫苗就是這樣)
- 結果是急性的、時點明確的
- 未測量的、不隨時間變的干擾是主要威脅——這正是DAG 上那些灰色節點
- 找不到合適的對照組,或者暴露很快就普及到全人群(疫苗接種活動)
反過來,暴露是慢性的、結果的起病時間模糊、或者主要的威脅是時間相依的干擾時,回去用世代研究加上加權比較實際。
為什麼這一頁沒有推薦影片
本站的影片清單經過實際驗證才收錄。這個主題在全部語言裡都找不到教學等級的影片——中文(繁體與簡體)完全沒有,英文只有三支研討會或講座的側錄(觀看數在數百之譜,長度三十到六十分鐘,簡報翻頁為主)。
與其硬塞,這裡直接說明現況。真的想聽人講的話,目前最接近的三支是 Leicester RSS Hub 的 Self-controlled case series methodology(32 分鐘,方法學導向)、case-crossover 原創者 Malcolm Maclure 本人的 Self controlled Design(60 分鐘),以及 OHDSI 的 Vaccine safety evaluation using SCCS(58 分鐘,應用導向)。它們都是給已經懂的人聽的,不是入門教材。
比較有效率的路徑是先讀本頁,再直接讀頁尾那篇 CC BY 的回顧文——它有完整的術語表與註解式書目。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 用 SCCS 研究以死亡為結果的問題 | 事件終止了觀察期,第三個假設直接破功 |
| 出事後醫師會停藥的情境仍用標準 SCCS | 事件影響了後續暴露機率,IRR 會被高估 |
| 用 CCO 研究天天吃的慢性用藥 | Persistent user bias,只有剛開始吃就出事的人提供訊息 |
| 暴露的處方量有明顯時間趨勢卻用單向 CCO | 對照窗不再代表「平常」,估計被系統性推高 |
| 用自我對照設計研究起病時間模糊的疾病 | 焦點窗與對照窗會劃錯,事件被歸到錯的時段 |
| 觀察期長達數年卻不校正年齡與季節 | 這個設計只消掉不隨時間變的東西 |
| 從自我對照分析報「每萬人多幾例」 | 分母被條件掉了,這個設計估不出絕對風險 |
| 沒說明有多少人實際貢獻了訊息 | 只有同時有事件又有暴露的人進得了概似 |
| 把 IRR 說成「跨人」的相對風險 | 它估的是同一個人不同時期的比值,問的是「為什麼是現在」 |
| 只做自我對照分析就宣稱排除了所有干擾 | 時間相依的干擾原封不動 |
| 未達顯著寫成「該藥不會引起這個事件」 | 只能說本分析未偵測到風險升高 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B6-05-self-controlled.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
同一份模擬資料,把所有人的暴露期人時跟所有人的非暴露期人時放在一起比,估出發生率比 4.57,而模擬設定的真值是 3。多出來的那一段是什麼?
看答案與解析
正確答案: 暴露組與未暴露組在一個沒被測量到的體質變項上差了 0.83 個標準差,跨人比較把這段差距算進了藥效裡
跨人比較的分母是每一個沒用過藥的人的人時,而那些人的體質風險本來就比較低——暴露組與未暴露組的未測量體質差了 0.83 個標準差,這段差距整個被記在藥的頭上。這是系統性的偏誤,不是抽樣波動:把樣本數加大,4.57 只會估得更精準,不會靠近真值。1.00 是模擬裡體質變異本身的標準差,它是偏誤的原料而不是偏誤的大小。跨人比較沒有辦法修好這件事,因為體質不在資料裡——這正是自我對照設計把比較搬進同一個人身上的理由。
這份模擬有 4000 個人、一共發生 4084 次事件。SCCS 估出 3.01,它是用多少資料算出來的?
看答案與解析
正確答案: 只有同時有事件又有暴露的那 1017 位病人的 2555 次事件進得了概似,其他人在概似裡是常數
條件式概似只吃「同時有事件又有暴露」的人:沒有事件的人與沒有暴露的人,他們對概似的貢獻是一個不含參數的常數,微分掉了。所以 4084 次事件裡只有 2555 次來自那 1017 位病人。274 是這 2555 次裡落在風險窗內的那些,它是分子那一側,不是全部的資料;把風險窗外的事件丟掉,就沒有東西可以估「平常的頻率」,比值也就無從算起。這也是報告 SCCS 時一定要寫出實際貢獻人數的理由——4000 個人裡真正撐起這個估計的只有 1017 位。
Case-crossover 那一列一共有 3052 個配對組,一次事件一組。條件式邏輯迴歸真正用到的是幾組?
看答案與解析
正確答案: 只有 445 組——焦點窗與對照窗的暴露狀態不一致的那些,其餘的組在條件式概似裡是常數
條件式邏輯迴歸只在分層內部比較,所以一個分層裡若事件當天與三個對照時點的暴露狀態全都一樣,它對概似就沒有貢獻。3052 組裡只有 445 組是不一致的,不到七分之一。1168 是「只用第一次事件」那一版的分層數,它回答的是另一個問題,而且那一版有訊息的組數更少。這件事讀者只看估計表是看不出來的:表面上有幾千組,實際撐起估計的是幾百組,而區間的寬窄由後者決定。所以報告 case-crossover 時要寫出有訊息的分層數。
Case-crossover 從「每一次事件都用」改成「只用第一次事件」,其他都不變,估計從 2.83 跳到 4.62。這個跳動說的是什麼?
看答案與解析
正確答案: 2.83 比較可信:被選為「第一次」的那些事件,對照窗系統性地比較不可能處在暴露狀態,只用第一次會把估計推高
「第一次」不是一次隨機抽出的事件。如果某個人的對照時點剛好落在用藥期間,他在那段時間的事件率是平常的三倍,很可能就在那時候出了第一次事,於是那一次才是「第一次」,現在這一次就不會被選中。結果是被選中的事件,它們的對照窗比平常更不可能處在暴露狀態,分母被壓小,估計被推高,2.83 因此變成 4.62。2.89 是把三個對照窗改成一個、但仍然使用全部事件的估計,它跟 2.83 幾乎一樣,所以高估的來源是樣本限制而不是對照窗的個數。教訓是:對照窗代表不代表「平常」,是這個設計唯一要保護的東西,而任何看起來無害的樣本限制都可能破壞它。
把資料改成「事件一發生就終止追蹤」,其他都不變,SCCS 估出 3.87,95% CI 是 3.05 到 4.91。這代表什麼?
看答案與解析
正確答案: 區間下界 3.05 已經在真值之上:第三個假設(事件不能影響觀察期長度)被破壞,整個估計被推高
SCCS 是雙向的,它靠的就是「觀察期不會因為事件發生而結束」。一旦事件終止追蹤,事件之後的那段對照時間整段消失,對照窗被系統性地縮短,比值就被推高:3.87 比真值高了將近三成,而且下界 3.05 完全沒有涵蓋真值。這不是變得保守,是偏了。3.41 是完整資料那一版的區間上界,兩個版本的區間幾乎不重疊,差別不在精確度而在偏誤。實務上的意思是:結果是死亡或高致死率時不宜直接用標準 SCCS,改用 case-crossover、專門處理事件相依觀察期的延伸版,或把追蹤起點改成第一次暴露那一刻。
落在風險窗內的事件一共 274 次。跨人比較用它們估出 4.57,SCCS 用同一批事件估出 3.01。同一批事件為什麼給出不同的答案?
看答案與解析
正確答案: 因為跨人比較的分母是全部 3810 次風險窗以外的事件,其中包含從來沒用過藥的人,而那些人的基線風險本來就比較低
分子那一側是同一批 274 次事件,不同的是分母。跨人比較拿它們去比全部 3810 次風險窗以外的事件,而那些事件大半來自從來沒用過藥的人;他們的體質風險比較低,分母被稀釋,比值就被推高。SCCS 只拿同一個人在風險窗外的時間當分母,體質在相減時抵消掉了。第三個選項有一半是真的:SCCS 的條件式概似確實只吃「同時有事件又有暴露」的人,1945 位有事件的人裡有一部分進不去。但人數不是這個落差的機制——分子那一側本來就是同一批事件,而差距的方向是可以事先推出來的(適應症干擾把跨人比較往上推),「樣本不同所以估計不一樣」則說不出任何方向。
用到這個方法的章節
素材來源與授權
- Core Concepts: Self-Controlled Designs in Pharmacoepidemiology (Bots et al., Pharmacoepidemiol Drug Saf 2025)CC BY本頁的術語(anchor/focal window/referent window)、SCCS 的三個假設與 case-crossover 的核心假設依該文整理,文字為原創改寫,模擬與所有數字為本站自行產生。