目標試驗模擬
先把「如果可以做,我會做哪一個隨機試驗」的七個要素寫下來,再逐項對照觀察性資料能不能做到——這個紀律專門對付 immortal time bias、prevalent user bias 與沒有主動對照這三個最常見的設計錯誤,它們光靠統計校正都修不好;至於未測量的干擾,它修不了。
這一頁在解決什麼問題
前面五頁全部在處理分析上的問題:該校正什麼、怎麼配對、怎麼加權、干擾因子沒測量到怎麼辦。這一頁處理的是更前面的東西——設計。
理由是一個令人不太舒服的事實:觀察性研究最嚴重的錯誤,多半不是統計方法選錯,是研究問題根本沒有被寫清楚。 「這個藥有沒有效」不是一個可以拿去分析的問題,因為它沒有回答:跟什麼比?從哪一刻開始算?誰算是用了藥?用了三天算嗎?中途停藥的人怎麼辦?
這些問題在隨機對照試驗裡不可能不回答——你必須先寫好 protocol 才收得到第一個病人。但在資料庫研究裡,資料已經在那裡了,你可以直接開始跑模型,於是那些問題就被跳過了,而跳過的後果會以偏誤的形式回來。
目標試驗模擬(target trial emulation)的作法是把試驗的紀律搬過來:先寫下「如果倫理與成本都不是問題,我會做哪一個隨機試驗」,把七個要素逐項填完,然後才問觀察性資料裡的哪些人、哪些時間點,可以對應到那份 protocol。
它不是一種統計方法,是一份填空表。而它能修好的東西,正好是統計方法修不好的那些。
目標試驗的七個要素
| 要素 | 試驗裡怎麼寫 | 資料庫研究裡最常出的錯 |
|---|---|---|
| 1. 納入條件(eligibility) | 在隨機分配之前就判定完畢 | 用了「未來才知道」的資訊篩人(例如「有完成療程的病人」) |
| 2. 治療策略(treatment strategies) | 每一組要做什麼,寫到可以照著執行 | 「有沒有用過這個藥」不是策略,沒有定義劑量、療程、停藥怎麼算 |
| 3. 分派方式(assignment procedure) | 隨機,且說明是否設盲 | 觀察性資料無法隨機,這一格要改寫成「在哪些共變項的條件下,分派可視為隨機」 |
| 4. 追蹤期間(follow-up period) | 從隨機分配那一刻開始,到某個明確的終點 | 時間零點沒有定義,或每一組的時間零點不一樣 |
| 5. 結果(outcome) | 事先定義好,含測量方式與時窗 | 用資料庫裡剛好有的代碼湊,不談誤分類 |
| 6. 因果對比(causal contrast) | intention-to-treat 還是 per-protocol,事先講明 | 沒有說在估哪一個,於是分析在兩者之間漂移 |
| 7. 分析計畫(analysis plan) | 主分析、次分析、敏感度分析,全部事先寫好 | 看到結果之後才決定要校正什麼、要不要分層 |
七格裡,第一、第三、第四格是整個框架的重心,因為它們對應到三個最常見也最嚴重的設計錯誤。
三個設計錯誤
Immortal time bias(不死時間偏誤)
如果分組的依據要等到基線之後才知道(「有沒有在追蹤期間開始用這個藥」),那麼從基線到開始用藥的那一段時間,被分到用藥組的人依定義不可能死——不然他們不會出現在用藥組。這段時間被算進用藥組的存活,用藥組就憑空多出一截保證活著的時間。
設限與存活資料的結構那一頁用真實的史丹佛換心資料完整示範過這個機制,這裡不重複。目標試驗框架的貢獻是告訴你它為什麼會發生:試驗裡不可能有這個問題,因為時間零點就是隨機分配那一刻,而分配的依據在那一刻已經確定。資料庫研究之所以會犯,是因為「時間零點」這一格是空的。
Prevalent user bias(盛行使用者偏誤)
如果研究窗打開的時候,把「已經在吃這個藥」的人直接收進用藥組,那麼收到的是已經吃了一段時間而且沒事的人。真正容易出事的那些——吃了兩週就出現副作用而停藥、或者已經發生事件的——根本進不了你的世代。
這叫做易感者耗竭(depletion of susceptibles),它系統性地讓藥看起來比實際安全。試驗裡不可能發生,因為試驗只收還沒開始用藥的人。對應的修法就是 new-user design:時間零點釘在開始用藥那一刻,只收在那一刻之前沒用過的人。
沒有主動對照(confounding by indication)
「用藥 vs 沒用藥」這個比較有一個結構性的問題:沒用藥的人是一群「醫師覺得不需要用藥」的人。他們與用藥的人在健康狀態、就醫頻率、共病、以及一大堆沒被記錄下來的東西上都不一樣。
主動對照(active comparator)的作法是拿「同一個適應症下的另一個藥」當對照。兩組都是「醫師認為需要治療的人」,所以那些決定「要不要治療」的未測量因素在兩組間大致抵消——剩下的只有「為什麼選 A 不選 B」,那通常是比較窄、比較容易測量的一組因素。
一份模擬資料,七種分析
模擬設定:8000 位病人在一個資料庫裡被追蹤 84 個月,研究窗在第 24 個月之後打開。有兩個藥治同一個適應症——藥 A 的真實效果是把死亡風險降到 0.80(HR),藥 B 沒有效果。兩個干擾因子:
- L,一個有被記錄的嚴重度指標。它同時影響「會不會被治療」與「被開哪一個藥」。
- U,一個沒有被記錄的體弱程度。它影響「會不會被治療」與死亡,但不影響選 A 還是選 B。
模擬出來的資料確實長成這樣:有治療與沒治療的人,U 差了 0.49 個標準差;而用 A 與用 B 的人,U 只差 -0.082,L 卻差了 0.65。這一組對照就是主動對照設計的全部理由。
library(survival)
set.seed(20260822)
N <- 8000; RUN_IN <- 24; END <- 84; TRUE_HR <- 0.80
L <- rnorm(N) # 嚴重度:有記錄
U <- rnorm(N) # 體弱:沒記錄
p_init <- plogis(-4.4 + 0.7 * L + 0.7 * U) # 每月開始用「某個藥」的機率
is_A <- rbinom(N, 1, plogis(0.8 * L)) # 用哪一個藥,只跟 L 有關
alive <- rep(TRUE, N); init <- rep(NA_integer_, N)
onA <- rep(FALSE, N); onB <- rep(FALSE, N); death <- rep(NA_integer_, N)
for (m in 1:END) {
start <- alive & is.na(init) & (runif(N) < p_init)
init[start] <- m
onA <- onA | (start & is_A == 1); onB <- onB | (start & is_A == 0)
h <- 0.0055 * exp(0.6 * L + 0.6 * U + log(TRUE_HR) * onA)
d <- alive & (runif(N) < pmin(h, 1))
death[d] <- m; alive[d] <- FALSE
}
# ── 天真做法:研究窗打開時還活著的人,按「曾不曾用過 A」分組 ────────
# 盛行使用者留著(init <= 24),時鐘從第 24 個月開始 → 兩個錯誤疊加
# ── 目標試驗版:只收在研究窗之後才開始用藥的人(new user),
# 時間零點 = 開始用藥那一個月,對照組是同期開始用 B 的人(active
# comparator),再對 L 做 IPTW ────────────────────────────────────
ac <- data.frame(id = which(!is.na(init) & init > RUN_IN))
ac$arm <- as.integer(is_A[ac$id] == 1)
ac$L <- L[ac$id]
ac$time <- pmin(ifelse(is.na(death[ac$id]), END, death[ac$id]), END) - init[ac$id] + 1
ac$event <- as.integer(!is.na(death[ac$id]) & death[ac$id] <= END)
ac <- ac[ac$time > 0, ]
ps <- fitted(glm(arm ~ L, data = ac, family = binomial()))
ac$w <- ac$arm / ps + (1 - ac$arm) / (1 - ps)
coxph(Surv(time, event) ~ arm, data = ac, weights = ac$w, robust = TRUE)驗證環境:R 4.6.0 + survival 3.8.6;模擬與七種分析全部用 base R 與 survival
import numpy as np, pandas as pd, statsmodels.api as sm
from scipy.special import expit
from lifelines import CoxPHFitter
rng = np.random.default_rng(20260822)
N, RUN_IN, END, TRUE_HR = 8000, 24, 84, 0.80
L, U = rng.normal(size=N), rng.normal(size=N)
p_init = expit(-4.4 + 0.7 * L + 0.7 * U)
is_A = rng.binomial(1, expit(0.8 * L))
alive = np.ones(N, bool); init = np.full(N, -1); death = np.full(N, -1)
onA = np.zeros(N, bool)
for m in range(1, END + 1):
start = alive & (init < 0) & (rng.random(N) < p_init)
init[start] = m
onA |= start & (is_A == 1)
h = 0.0055 * np.exp(0.6 * L + 0.6 * U + np.log(TRUE_HR) * onA)
d = alive & (rng.random(N) < np.minimum(h, 1))
death[d] = m; alive[d] = False
idx = np.flatnonzero((init > RUN_IN))
ac = pd.DataFrame({"arm": (is_A[idx] == 1).astype(int), "L": L[idx]})
end = np.where(death[idx] < 0, END, np.minimum(death[idx], END))
ac["time"] = end - init[idx] + 1
ac["event"] = ((death[idx] > 0) & (death[idx] <= END)).astype(int)
ac = ac[ac.time > 0]
ps = sm.Logit(ac["arm"], sm.add_constant(ac[["L"]])).fit(disp=0).predict()
ac["w"] = ac["arm"] / ps + (1 - ac["arm"]) / (1 - ps)
CoxPHFitter().fit(ac[["time", "event", "arm", "w"]], "time", "event",
weights_col="w", robust=True).print_summary()Python 側用 numpy 產資料、lifelines 跑 Cox;邏輯與 R 版本逐行對應。
figures/scripts/B6-06-target-trial.R| 分析設計 | 分析 n(A/對照) | 事件數 | HR | 95% CI | 問題在哪 |
|---|---|---|---|---|---|
| 真實效果(模擬設定值) | — | — | 0.80 | — | — |
| 盛行使用者 + 基線就按「曾用藥」分組 | 5092(2003/3089) | 1482 | 1.13 | 1.02–1.25 | 兩個設計錯誤疊在一起 |
| 新使用者 vs 未治療,但時鐘從研究窗開始 | 5737(870/4867) | 1015 | 0.78 | 0.66–0.93 | immortal time |
| 新使用者 vs 未治療,未校正 | 5737(870/4867) | 1015 | 1.34 | 1.13–1.59 | 適應症干擾 |
| 新使用者 vs 未治療,校正已測量的 L | 5737(870/4867) | 1015 | 1.01 | 0.85–1.20 | 未測量的 U 還在 |
| 新使用者 + 主動對照,未校正 | 1778(870/908) | 339 | 0.93 | 0.75–1.15 | U 被抵消,L 還在 |
| 新使用者 + 主動對照,校正 L | 1778(870/908) | 339 | 0.79 | 0.63–0.99 | 涵蓋真值 |
| 新使用者 + 主動對照 + IPTW | 1778(870/908) | 339 | 0.78 | 0.62–0.99 | 涵蓋真值 |
「分析 n」是該列模型實際跑的那一份資料的人數,不是整個模擬世代的人數——設計一改,被納入的人就跟著換掉,所以每一列的 n 與事件數都要跟著 HR 一起讀。
從上往下讀,每一列只改一件事:
- 第一列是最常見的那種分析:研究窗打開時還活著的 6777 人裡,扣掉用過藥 B 的 1685 人,剩下 5092 人按「有沒有用過藥 A」分組(用過藥 B 的人既不在用藥組也不在對照組,這個排除本身就該報出來)。其中 1133 位是盛行使用者(研究窗打開前就在用了),而所有人的時鐘都從研究窗開始算。估出 1.13——這個藥真的能把死亡風險降兩成,天真的分析卻說它有害。
- 第二列與第三列只差一件事:時間零點。兩列用的是完全相同的兩組人,第三列的用藥組時鐘從開始用藥那一刻起算(沒有不死時間),第二列從研究窗起算(於是那 21125 個人月的「還沒開始用藥」時間被算進了用藥組的追蹤)。HR 從 1.34 變成 0.78。這個差距完全是 immortal time 造成的,不是別的。
- 第三列到第四列:校正已測量的 L,把 1.34 拉到 1.01。有進步,但還是離真值很遠——因為未測量的 U 還在那裡。
- 第三列到第五列:換成主動對照(藥 A vs 藥 B 的新使用者)。兩列都沒有校正任何共變項,光是換對照組,估計就從 1.34 掉到 0.93——U 在兩組間抵消掉了,一個共變項都沒多校正。(第四列的 1.01 是校正過 L 的,不能拿來跟第五列比:那樣一次就改了兩件事。)
- 第六、七列:再把 L 處理掉(迴歸校正或 IPTW),估到 0.79 與 0.78,信賴區間涵蓋真值 0.80。
時間零點:三條線的差別
figures/scripts/B6-06-target-trial.R時間零點必須同時滿足三件事,這是目標試驗框架最實用的一條檢查:
- 納入條件在那一刻就已經判定完畢——不能用之後才知道的資訊
- 治療策略在那一刻就已經指定——不能等後來才知道他屬於哪一組
- 追蹤從那一刻開始——三者必須是同一個時刻
三者不對齊,就會產生偏誤。new-user design 之所以有效,就是因為「第一次領到這個藥」這一刻同時滿足這三個條件。
這一頁怎麼串起前面五頁
目標試驗的七格填空,每一格都會用到前面的某一頁:
| 目標試驗的要素 | 觀察性資料裡靠什麼做到 |
|---|---|
| 納入條件在時間零點判定完畢 | new-user design;設限那頁的 landmark 分析是另一種修法 |
| 治療策略講清楚 | 定義劑量、療程、停藥與換藥怎麼算;牽涉到競爭風險時要先想清楚終點 |
| 分派可視為隨機 | 這是「條件可交換性」的另一種說法——要靠 DAG 決定條件在哪些變項上,而它只是三個可辨識性假設的其中一個 |
| 分派的實作 | 配對、加權;共變項沒測量到時只剩 工具變數 |
| 對照組怎麼選 | 主動對照;世代研究那章的殘餘干擾在這裡被設計掉一部分 |
| 追蹤期間 | 時間零點三條件;immortal time bias |
| 因果對比與分析 | ITT 還是 per-protocol;per-protocol 要處理隨時間改變的暴露,見邊際結構模型 |
還有一條斜著的連線:自我對照設計是把「對照」放到同一個人身上的另一種答案。 它不需要主動對照、不需要 propensity score,因為它從一開始就不做跨人的比較。三角驗證的價值就在這裡——兩種假設幾乎不重疊的設計得到一致的答案,比任何單一分析都有說服力。
什麼時候模擬不了目標試驗
這個框架的一個副作用是它會告訴你「這個問題用這份資料回答不了」。三種常見的情況:
- 時間零點在資料裡看不見。 例如要研究「開始運動」的效果,但資料庫裡沒有任何欄位標記一個人哪一天開始運動——那就沒有時間零點,任何分組都會是回溯的。
- 納入條件需要未來的資訊。 例如「持續用藥滿一年的病人」——這個條件要活過一年才判定得出來,它不能當納入條件,只能當結果。
- 找不到合理的主動對照。 這個藥是同類第一個,或者對照組必然是「不治療」。這時候未測量的適應症干擾就跑不掉,只能靠敏感度分析(E-value 之類)誠實地量化它。
得出「回答不了」也是一個結果,而且比硬做一個有結構性錯誤的分析有價值得多。它通常會指向另一個問題、另一份資料、或另一個設計。
為什麼這一頁沒有推薦影片
跟自我對照設計一樣,這個主題在全部語言裡都沒有教學等級的影片——中文完全沒有,英文只有三支五十七到八十分鐘的講座錄影。本站不為了湊數放品質不足的影片。
想聽的話,權威性最高的是 Miguel Hernán 本人主講的 Target Trial Emulation(80 分鐘),另外兩支是 Northwestern Feinberg(58 分鐘)與 NIHR RSS(57 分鐘)。三支都預設你已經懂 Cox 模型與 IPTW,所以先讀完 B6-01 到 B6-05 再看比較划算。
文字的入門是 Hernán 與 Robins 的原始方法學論文(Am J Epidemiol 2016),以及後續在 JAMA、BMJ 上的一系列說明文章。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 論文寫「我們做了 target trial emulation」但沒附那張七格表 | 這個框架的全部價值就在那張表;沒有表就只是一句標語 |
| 時間零點沒有明確定義 | 三個條件(納入、分派、追蹤起點)必須落在同一刻 |
| 用「追蹤期間曾不曾用藥」在基線分組 | Immortal time bias,見 B3-01 |
| 把研究窗打開時已在用藥的人收進用藥組 | Prevalent user bias,易感者已經被耗竭掉了 |
| 用「沒用藥的人」當對照卻不談適應症干擾 | 那群人是「醫師認為不需要治療」的人 |
| 納入條件用了未來才知道的資訊 | 那不是納入條件,是結果 |
| 沒說在估 ITT 還是 per-protocol | 兩者回答不同的問題,分析方式也不同 |
| per-protocol 分析忽略中途停藥與換藥 | 那需要時間相依的加權,不是排除掉不順從的人 |
| 因為估計看起來合理就相信設計是對的 | 方向相反的偏誤會互相抵消,本頁第二列就是例子 |
| 用目標試驗模擬取代對未測量干擾的討論 | 這個框架修的是設計錯誤,不是未測量干擾 |
| 未達顯著寫成「兩個藥效果相同」 | 只能說本分析未偵測到差異 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B6-06-target-trial.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
第二列帶著 immortal time bias,它估出 0.78,而模擬設定的真實風險比是 0.80。哪一句正確說明第二列為什麼會這麼接近真值?
看答案與解析
正確答案: 同樣那兩組人、只把時間零點釘回開始用藥那一刻,估計是 1.34——兩個方向相反的偏誤剛好抵消
第二列與第三列用的是完全相同的兩組人,唯一的差別是時間零點:第三列的用藥組時鐘從開始用藥那一刻起算,第二列從研究窗起算,於是一大段「還沒開始用藥」的人時被算進了用藥組的追蹤。光是這一個改動就讓風險比從 1.34 變成 0.78,所以瑕疵的量一點都不小,也確實動到了估計。第二列之所以貼近真值 0.80,是適應症干擾(把估計往上推)與不死時間(把估計往下推)剛好抵消,而區間上界 0.93 落在一以下只是抵消得剛好的副產品。目標試驗版最後估到的數字與它相同,但那是把三個設計錯誤逐一排除之後得到的,兩者的可信度完全不同。偏誤要靠設計排除,不能靠結果看起來合不合理來判斷。
第一列的天真分析估出 1.13,第三列的新使用者未校正分析估出 1.34。第一列比較接近一,哪一句正確說明這件事?
看答案與解析
正確答案: 第一列的時鐘全部從研究窗開始算,21125 個人月的不死時間被算進用藥組,它離一近但錯得更徹底
第一列的問題不在於它離一多遠,而在於它同時犯了三件事:盛行使用者被留下來,時鐘從研究窗而不是從開始用藥那一刻起算(21125 個人月的不死時間被算進用藥組),再加上適應症干擾。三個偏誤的方向不一樣,互相拉扯之後湊出一個看起來溫和的 1.13。第三列只犯了適應症干擾這一件,所以 1.34 反而是比較誠實的數字。多納入的 1133 位盛行使用者不是精確度的功勞,他們是被篩選過的一群,加進來買到的是更窄的區間配上更大的偏誤;1685 是被排除的、用過另一個藥的人數,那個排除本身該報出來,但把用過別的藥的人整批拿掉,也一併拿掉了唯一可以拿來當主動對照的那群人。「離一比較近」從來不是判斷偏誤的方法。
從第三列換到第五列只做了一件事:把對照組從「沒用藥的人」換成「同期開始用另一個藥的新使用者」,一個共變項都沒有多校正。估計從 1.34 掉到 0.93。為什麼?
看答案與解析
正確答案: 因為未記錄的體弱程度在兩個藥之間只差 -0.082 個標準差,它影響會不會被治療,不影響被開哪一個藥
主動對照之所以有用,是因為未測量的干擾在兩個治療組之間不再有差別:有治療與沒治療的人在體弱程度上差了 0.492 個標準差,而用兩個藥的人只差 -0.082,因為那個未記錄的變項影響「會不會被治療」,卻不影響「被開哪一個藥」。0.492 正是有治療與沒治療之間的那個差,不是換對照組之後縮小的殘差——它是第三列的病灶,不是第五列的成果。至於 0.649,那是已記錄的嚴重度在兩個藥之間的差,換對照組沒有處理它,第六、七列的校正與加權才是在處理它。換對照組換掉的,是那個你量不到的東西。
時間零點必須同時滿足三件事:納入條件在那一刻判定完畢、治療策略在那一刻指定、追蹤從那一刻開始。第三列在哪裡沒有做到?
看答案與解析
正確答案: 未治療組裡有 1778 人後來才開始用藥,他們用藥前的人時被算進未治療組,兩臂的時間零點不是同一個
第三列的用藥組時鐘釘在開始用藥那一個月,未治療組的時鐘卻釘在研究窗打開那一刻,兩臂的時間零點不是同一個。而且未治療組裡有 1778 人後來才開始用藥,他們用藥前的人時被算進未治療組,到開始用藥那個月才被設限,也沒有加上處理設限的權重,於是同一個人的不同時段同時出現在兩臂。870 是第三列用藥組的人數,那一臂確實把三個條件都對齊了,但一臂做對不等於時間零點對齊——問題出在另一臂。6777 是研究窗打開時還活著的全部人數,它是第一列篩選的起點,不是第三列對照組的人數,拿它當證據等於引錯了列。真的要做對,標準作法是把每一個月都當成一個小試驗做 risk-set 抽樣,中途換藥或停藥再用 clone-censor-weight 處理。
第四列對已測量的嚴重度做了校正,估計從 1.34 拉到 1.01,離真值 0.80 還很遠。哪一句正確說明第四列剩下的那段距離?
看答案與解析
正確答案: 第五列一個共變項都沒校正就估到 0.93,把估計拉動的是換對照組,不是校正
第四列校正的是有記錄的嚴重度,而讓估計偏掉的主力是沒有被記錄的體弱程度——那個變項不在資料裡,任何統計校正都碰不到它。1.01 與區間上界 1.20 都還在一的右側,而剩下的那段距離不是「還沒放夠變項」:資料裡能放的已經放了。真正把估計拉動的是第五列的換對照組,一個共變項都沒多校正就掉到 0.93,因為未測量的干擾在兩個治療組之間抵消了;第六列的 0.79 是在換過對照組之後才校正得到的,把它讀成「多校正的功勞」等於把兩個步驟的貢獻記錯帳。所以目標試驗框架處理的是不死時間、盛行使用者與缺少主動對照這三個設計錯誤;未測量的干擾要靠別的東西——主動對照、自我對照設計,或工具變數。
第一列裡有 1133 位是盛行使用者,也就是研究窗打開之前就已經在用藥的人。把他們留在分析裡有什麼問題?
看答案與解析
正確答案: 整個世代在研究窗之前就開始用藥的有 1338 人,第一列只收到其中 1133 位,中間的落差就是篩選
研究窗打開時把已經在吃藥的人直接收進用藥組,收到的是已經吃了一段時間而且沒事的人。整個世代在研究窗之前就開始用藥的是 1338 人,而第一列只收到 1133 位——中間差的那兩百多人在研究窗打開之前就死了,正是吃了藥之後早期出事的那一批,他們沒有機會進入這個分析。這叫易感者耗竭,它系統性地讓藥看起來比實際安全,而且它不是樣本數問題:第一列的 5092 人裡沒有人被重複計算,那個數字是篩選之後剩下的人數,不是需要去重的名單,缺的那群人根本沒有被觀察到,扣誰補誰都救不回來。2208 是整個世代曾經用過那個藥的總人數,把他們全部納入不會解決篩選,只會把篩選做得更徹底。修法是 new-user design:時間零點釘在開始用藥那一刻,只收在那一刻之前沒用過的人——試驗裡不可能發生這個偏誤,正是因為試驗只收還沒開始用藥的人。
用到這個方法的章節
素材來源與授權
本頁為原創內容