左截切與延遲進入
有人不是從第 0 天開始被觀察的。左截切與左設限差在哪、Surv(entry, exit, event) 這個兩端時間的寫法、不寫會把存活率往哪個方向偏多少,以及為什麼偏誤全部落在延遲進入的那一群人身上。
左截切與左設限,差在「有沒有進入分母」
這兩個詞長得像,中文譯名也常被混用,但它們講的是完全不同的兩件事,處理方式也完全不同。
| 左設限(left censoring) | 左截切(left truncation) | |
|---|---|---|
| 你知道的事 | 事件已經發生了,只是時間不知道,只知道早於某個時點 | 這個人在某個時點之前根本不可能被看到發生事件 |
| 典型情境 | 第一次抽血就已經陽性,不知道何時感染的 | 登錄資料的收案日晚於診斷日;以年齡當時間軸 |
| 缺的是什麼 | 缺一個時間 | 缺一段觀察 |
| 怎麼處理 | 改用左設限/區間設限的概似式(Surv(time, time2, type = “interval2”)) | 把進入時間寫進 Surv(),讓這個人在進入之前不被算進分母 |
設限與風險集合那一頁已經把「設限是人在資料裡、時間不完整;截切是人根本沒進來」講過,本頁不重複那一段。本頁處理的是左截切比較溫和、也比較常見的那個形態:人進得來,只是進得晚——而那個晚到的區間,正好是他不可能被觀察到發生事件的區間。
這一頁的例子:一份收案日晚於診斷日的骨髓瘤登錄
survival::myeloma 是 Mayo Clinic 的多發性骨髓瘤(multiple myeloma)病人資料,3882 人,追蹤到 2769 人死亡、1113 人(28.7%)在追蹤結束時仍存活。
關鍵在時間原點:這份資料的第 0 天是「診斷」,不是「收案」。 在 Mayo 當場診斷的人,兩者是同一天;在外院診斷、後來才轉介過來的人,兩者差了一段。這段差距就記在 entry 這個欄位裡。
| 人數 | 占比 | |
|---|---|---|
entry 為 0——診斷當天就進入觀察 | 2194 | 56.5% |
entry 大於 0——診斷之後才進入觀察 | 1688 | 43.5% |
只看整體的 entry 中位數會得到 0,因為超過一半的人是當場收案的,那個數字什麼都沒告訴你。要看的是延遲進入的那群人自己的分佈:中位延遲 166 天,四分位距 36 到 575 天,最長的一位是在診斷後 5414 天才進入觀察的。
寫法:把進入時間放進 Surv() 的第一個位置
Surv() 吃兩個時間就是在講這件事:這個人從什麼時候開始被觀察、到什麼時候結束。
library(survival)
data(cancer, package = "survival") # myeloma 住在 cancer 這個 bundle 裡
# 注意:data(myeloma, package = "survival") 會警告「找不到這個資料集」
# 忽略延遲進入:每個人都被當成從第 0 天(診斷)就開始被觀察
fit_naive <- survfit(Surv(futime, death) ~ 1, data = myeloma)
# 正確:entry 是這個人真正進入風險集合的時間(診斷後第幾天)
fit_corr <- survfit(Surv(entry, futime, death) ~ 1, data = myeloma)
summary(fit_naive)$table[c("median", "0.95LCL", "0.95UCL")]
summary(fit_corr)$table[c("median", "0.95LCL", "0.95UCL")]
# Cox 完全同一個寫法,把兩端時間放進 Surv() 就好
coxph(Surv(entry, futime, death) ~ year, data = myeloma)驗證環境:R 4.6.0 + survival 3.8.6。myeloma 隨 survival 套件附帶,不必另外安裝。
import pandas as pd
from lifelines import KaplanMeierFitter, CoxPHFitter
# statsmodels 的 get_rdataset("myeloma", "survival") 會失敗:Rdatasets 的索引
# 把它登記成 "myeloma (cancer)",跟 R 那邊「住在 cancer bundle 裡」是同一件事。
# 直接讀 CSV 最省事。
URL = "https://vincentarelbundock.github.io/Rdatasets/csv/survival/myeloma.csv"
mye = pd.read_csv(URL)
km_naive = KaplanMeierFitter().fit(mye["futime"], mye["death"])
km_corr = KaplanMeierFitter().fit(mye["futime"], mye["death"], entry=mye["entry"])
print(km_naive.median_survival_time_, km_corr.median_survival_time_)
# Cox:entry_col 就是 Surv() 的第一個時間
cph = CoxPHFitter().fit(
mye[["entry", "futime", "death", "year"]],
duration_col="futime", event_col="death", entry_col="entry",
)
print(cph.summary[["coef", "se(coef)", "p"]])lifelines 的 KaplanMeierFitter 有 entry= 參數、CoxPHFitter.fit() 有 entry_col= 參數,兩者都實跑對過本頁 R 的結果(中位存活與 Cox 係數逐位對上)。唯一的差別在取得資料:statsmodels 的 get_rdataset 找不到 myeloma,原因與 R 那邊同源,見下方註解。
不處理會偏多少、往哪個方向偏
同一份資料、同一批人、同一個事件定義,只差 Surv() 裡多不多一個時間欄位:
figures/scripts/B3-08-left-truncation.R| 寫法 | 中位存活(95% CI) | S(500 天) | S(1000 天) | S(2000 天) |
|---|---|---|---|---|
忽略延遲進入survfit(Surv(futime, death) ~ 1, data = myeloma) | 1004(952–1060) | 71.8% | 50.4% | 26.2% |
正確處理延遲進入survfit(Surv(entry, futime, death) ~ 1, data = myeloma) | 764(728–811) | 63.2% | 41.0% | 19.2% |
| 差(前者減後者) | 240 天 | 8.5 個百分點 | 9.4 個百分點 | 7.0 個百分點 |
方向是固定的,不是這份資料剛好如此:忽略延遲進入一定會高估存活。 中位存活從 764 天被推到 1004 天,多了 240 天,是正確值的 1.31 倍;第 1000 天的存活率從 41.0% 被推到 50.4%,差 9.4 個百分點。
以百分點看,上表這三個時點裡以第 1000 天的落差最大(9.4 個百分點),到第 2000 天縮回 7.0 個百分點——那不是偏誤變小了,是兩條曲線都已經接近底部,能差的空間本來就變窄了。
機制:分子完全一樣,分母不一樣
要把這件事講清楚,最好的切入點是:兩種算法的事件數是同一個數字。
兩邊都是 2769 個死亡,一個不多一個不少——沒有任何一個事件被加進來或拿掉。變的只有每個事件發生時,KM 拿來當分母的那個風險集合有多大。分母被灌大,每一步的「這一刻死掉的比例」就被稀釋,乘起來的存活曲線就整條被抬高。
figures/scripts/B3-08-left-truncation.R| 時點(天) | 忽略延遲進入的分母 | 正確處理延遲進入的分母 | 還沒進入觀察 | 占天真分母 |
|---|---|---|---|---|
| 0 | 3882 | 2194 | 1688 | 43.5% |
| 500 | 2335 | 1859 | 476 | 20.4% |
| 1000 | 1469 | 1228 | 241 | 16.4% |
| 1500 | 929 | 805 | 124 | 13.3% |
| 2000 | 602 | 534 | 68 | 11.3% |
| 2500 | 387 | 352 | 35 | 9.0% |
| 3000 | 235 | 219 | 16 | 6.8% |
第 0 天的落差 1688 人,正好等於 entry 大於 0 的人數——不是近似,是同一群人。之後這個落差隨著大家陸續被轉介而縮小,但到第 2000 天,天真分母裡還有 11.3% 是不該在那裡的人。
偏誤不是平均攤在每個人身上
把樣本拆成兩群分開跑,會看到這一頁最有說服力的東西。
| 子群 | 人數 | 事件數 | 忽略延遲進入的中位(95% CI) | 正確處理延遲進入的中位(95% CI) | 差 |
|---|---|---|---|---|---|
| 在本院診斷、當場收案(entry 為 0) | 2194 | 1889 | 777(729–823) | 777(729–823) | 0 天 |
| 在外院診斷、後來才轉介進來(entry 大於 0) | 1688 | 880 | 1462(1339–1581) | 795(734–885) | 667 天 |
當場收案那一群,兩種寫法給出一模一樣的中位存活(777 天),連信賴區間的兩端都一樣。這不意外——他們的 entry 本來就是 0,兩個公式對他們說的是同一句話。
轉介那一群,兩種寫法差 667 天。整份分析的偏誤,百分之百落在這 1688 個人身上。
你這學期就會撞到的兩個情境
一、以年齡當時間軸的世代研究
在慢性病流行病學裡,時間軸常常不是「追蹤了幾年」,而是年齡——因為對心血管事件、失智、骨折這類結果來說,年齡是最強的那個時間尺度,用追蹤時間當軸再把年齡當共變項調整,是把最重要的東西降級處理。
一旦時間軸換成年齡,每個人都是左截切的:一位 62 歲收案的受試者,他從 0 歲到 62 歲那段沒有被你觀察,而且他能出現在你的收案名單裡,前提就是他活到了 62 歲。
# 時間軸 = 年齡(歲)。收案年齡是進入,結束追蹤時的年齡是離開。
# 用上面已經載入的 lung 當形狀示範:age 是收案年齡,time 是追蹤天數。
cohort <- data.frame(
age_entry = lung$age,
futime_years = lung$time / 365.25,
event = as.integer(lung$status == 2),
sex = factor(lung$sex, levels = c(1, 2), labels = c("Male", "Female")),
ecog = lung$ph.ecog
)
cohort$age_exit <- cohort$age_entry + cohort$futime_years
coxph(Surv(age_entry, age_exit, event) ~ ecog + sex, data = cohort)
二、登錄/生物資料庫研究:收案日晚於疾病起始日
這是台灣醫學生做健保資料或院內登錄第一個會踩的坑。凡是資料的時間原點與進入資料庫的時點不是同一天,中間那段就是左截切:
- 院內癌症登錄以診斷日為原點,但病人是轉診進來才被建檔的
- 生物資料庫以發病日為原點,但檢體是後來才送到、才被納入分析
- 用某年開始的資料庫回溯研究「該年之前就已經診斷」的病人——這些人的原點在資料庫開始之前,他們能出現在資料裡就代表他們活到了資料庫啟用的那一天
- 以「第一次處方」為原點,但資料庫只從某年開始有處方檔
寫法都一樣:entry = 原點到進入資料庫的天數,exit = 原點到事件或追蹤結束的天數。資料庫研究與真實世界資料那一章講的其他坑(申報資料不是為研究而存在、RECORD 要求交代哪些東西)與這一條是疊加的,不是替代的。
這一頁與其他頁的關係
- 設限與截切的基礎、以及風險集合為什麼是分母——回到 設限與風險集合。那一頁的「三種設限」表格與本頁第一節的表格是一組的。
- KM 曲線本身怎麼算、風險人數表為什麼一定要附——見 Kaplan-Meier 曲線。本頁的圖一之所以把兩列風險人數畫出來,就是因為那兩列正是兩條曲線分開的全部原因。
- Cox 模型——見 Cox 比例風險模型。左截切在
coxph()裡是完全相同的一行改動,partial likelihood 的每一個 risk set 會自動照entry篩過。 - Immortal time bias——它與延遲進入是親戚,但不是同一件事,值得分清楚:
- 延遲進入:那段時間任何人都沒在觀察他,錯在把不存在的觀察算成觀察。
- Immortal time bias:那段時間你看得到他,但你用了一個在當時還不知道的狀態(有沒有接受移植、有沒有反應)去分組,於是把「保證活著」的時間記到了錯的組。 兩者的修法方向相反:前者要把時間移出風險集合,後者要把時間留在風險集合裡、但記到正確的那一組。 B3-01 用史丹佛換心資料走過 immortal time 的對照, 時間相依共變項與 landmark 分析則是它的正解。
- 「能被納入」本身就是一種篩選——見 選擇偏誤。
Surv(entry, ...)修的是分母,修不掉族群已經被篩過這件事。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
收案日晚於原點,卻寫 Surv(time, event) | 不會報錯,只會把存活率系統性地高估;本頁的例子偏了 240 天 |
看整體 entry 中位數是 0 就認為不影響 | 偏誤只落在延遲進入的人身上,整體分位數會把它藏起來 |
用 exit - entry 當追蹤時間代替兩端時間 | 那是換掉時間原點,不是修正;臨床問題跟著變了 |
| 以年齡當時間軸卻只寫追蹤時間 | 每個人都是左截切,這是該情境下的預設而非例外 |
| 把左截切當成左設限去處理(區間概似式) | 左設限缺的是一個時間,左截切缺的是一段觀察,兩者的概似式不同 |
用 Surv(entry, exit, event) 之後宣稱選擇偏誤也解決了 | 只修好了分母;誰有機會被收案仍然是被篩選過的 |
| Methods 只寫「使用 Kaplan-Meier 法」 | 讀者無從判斷延遲進入有沒有被處理,而這是不可重現的關鍵一行 |
| 延遲進入處理好了就宣稱其他假設也滿足 | 非資訊性設限、比例風險都是另外的假設,要各自檢查 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B3-08-left-truncation.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
同一份 myeloma 資料,忽略延遲進入與正確處理延遲進入,算出來的中位存活不一樣。該報哪一個?
看答案與解析
正確答案: 764 天——把每個人的進入時間放進風險集合之後算的
被轉介來的人必須先活到轉介那一天,才有機會出現在這份資料裡,而那段時間他們依定義不會死。把他們當成從診斷日就開始被觀察,等於把那段不可能死亡的時間送給存活曲線,於是中位數從 764 天被推高到 1004 天。728 是正確版本那個中位數的信賴區間下界:換一批人就會變,把它當成點估計不是保守,是報了另一個量。
這份資料 3882 人。要判斷延遲進入是不是一個需要處理的問題,該看哪一個數字?
看答案與解析
正確答案: 1688 人的進入時間大於零,這些人的觀察是從診斷之後才開始的
1688 人是延遲進入的,接近全體的一半——這個比例決定了忽略它會有多大的偏差。2194 是進入時間為零的人,他們沒有問題,但「其餘的人可以先不管」正好倒過來了;1113 是被設限的人數,而延遲進入與設限是兩件相反的事:設限是「之後看不到了」,延遲進入是「之前沒看到」。這件事在資料表上只是一個 entry 欄位,分析時沒有把它放進 Surv() 的話,程式不會有任何抱怨,結果照樣跑得出來。
整體來看,忽略與正確處理延遲進入的中位存活只差 240 天,有人因此說這個問題可以忽略。哪一個數字最能反駁?
看答案與解析
正確答案: 延遲進入那一群差了 667 天,偏差全部集中在他們身上
進入時間為零的人本來就沒有延遲進入的問題,兩種算法對他們給出完全一樣的中位數,差是 0;偏差全部來自延遲進入的那一群,他們差了 667 天。整體那個 240 天是兩個子群混在一起被稀釋之後的結果——一半的人完全沒有偏差,把他們平均進來,當然看起來還好。所以「整體看起來差不多」不能拿來當作可以忽略左設限的理由,該看的是受影響那一群有多大、偏多少。
用到這個方法的章節
延伸觀看
Censoring and Truncation [Survival Analysis 2/8]素材來源與授權
本頁為原創內容