比例風險假設與 Schoenfeld residuals
比例風險假設實際上在假設什麼、cox.zph 的那張表怎麼讀、Schoenfeld 殘差圖與 log-log plot 各自看什麼,以及假設被違反之後分層 Cox、時間分段、時間相依係數三條路各自付出什麼代價。
這個假設在假設什麼
Cox 模型的形式是「基線風險乘上一個倍數」:
注意 沒有帶 。這代表那個倍數在整段追蹤期間都是同一個數:下面那個 lung 模型估出來的女性 HR 是 0.602,模型的意思是第 30 天、第 300 天、第 900 天,女性的死亡風險都是男性的 0.602 倍。這就是比例風險假設(proportional hazards assumption)。
它不是一個可以「附帶檢查一下」的技術細節,而是那個 HR 有沒有意義的前提。假設不成立時,coxph() 還是會吐出一個數字,但那個數字是把不同時期不同大小(甚至方向相反)的效果,用一個跟事件分布有關的權重平均起來的產物。它不對應到任何一個時點的真實風險比,而且它的大小會隨著追蹤期長短而變——同一個治療追蹤三年跟追蹤十年會得到不同的 HR,即使真實情況完全一樣。
臨床上假設會破功的典型場景:
| 場景 | HR 隨時間怎麼變 |
|---|---|
| 手術 vs 保守治療 | 早期手術風險高(術後併發症),晚期較低 → HR 先大於 1 再小於 1,曲線交叉 |
| 免疫治療 vs 化療 | 前幾個月看不出差異,之後才拉開 → HR 先接近 1 再變小 |
| 體能狀態、年齡這類預後因子 | 效果集中在早期,久了之後體弱的人已經死光,剩下的人差異變小 → HR 往 1 收斂 |
| 疫苗保護力 | 隨時間衰退 → HR 往 1 收斂 |
怎麼檢查:Schoenfeld residuals
Schoenfeld 殘差(Schoenfeld residuals)的想法很直接:在每一個事件時點,模型會依各人的共變項算出「誰最可能是這次發生事件的人」的預測值;殘差就是實際發生事件那個人的共變項值,減掉模型在該時點的預測值。
關鍵在於:如果比例風險成立,這些殘差與時間應該沒有關係——早期跟晚期的殘差都該在 0 附近隨機散布。如果殘差隨時間有系統性的趨勢,代表 其實在隨時間變化。
實務上用的是尺度化 Schoenfeld 殘差(scaled Schoenfeld residuals),它有一個很好的性質:把它對時間作圖,那條平滑曲線就是 的估計——直接看得到係數隨時間跑去哪裡。cox.zph() 做的正式檢定,就是在檢定這條線的斜率是不是 0。
動手跑一次
library(survival)
data(cancer, package = "survival")
lung$sex_f <- factor(lung$sex, levels = c(1, 2),
labels = c("Male", "Female"))
fit <- coxph(Surv(time, status) ~ age + sex_f + ph.karno + wt.loss,
data = lung)
zph <- cox.zph(fit)
zph # 每個變項一列 + GLOBAL
par(mfrow = c(2, 2)); plot(zph) # 平滑線就是 beta(t)
# log(-log) 圖:類別變項的圖形檢查,平行 = 假設沒問題
plot(survfit(Surv(time, status) ~ sex_f, data = lung),
fun = "cloglog", col = c("#4d6a8c", "#c44d4d"), lwd = 2)
# 補救一:把時間切開,讓係數在前後兩段各自估
sp <- survSplit(Surv(time, status) ~ ., data = lung, cut = 180,
episode = "period")
coxph(Surv(tstart, time, status) ~ age + sex_f + wt.loss +
ph.karno:strata(period), data = sp)
# 補救二:讓係數是時間的函數 beta(t) = b0 + b1*log(t)
coxph(Surv(time, status) ~ age + sex_f + wt.loss + ph.karno + tt(ph.karno),
data = lung, tt = function(x, t, ...) x * log(t))
# 補救三:分層——每一層有自己的 baseline hazard
lung$kstrata <- cut(lung$ph.karno, c(-Inf, 70, 80, Inf))
coxph(Surv(time, status) ~ age + sex_f + wt.loss + strata(kstrata), data = lung)
# 補救四(換掉效果量)的程式碼在 B3-07,那一頁是 RMST 的完整說明驗證環境:R 4.6.0 + survival 3.8.6
import statsmodels.api as sm
from lifelines import CoxPHFitter
lung = sm.datasets.get_rdataset("cancer", "survival").data
d = lung[["time", "status", "age", "sex", "ph.karno", "wt.loss"]].dropna().copy()
d["event"] = (d["status"] == 2).astype(int)
d["female"] = (d["sex"] == 2).astype(int)
d = d.drop(columns=["status", "sex"]).rename(columns={"ph.karno": "ph_karno",
"wt.loss": "wt_loss"})
cph = CoxPHFitter().fit(d, duration_col="time", event_col="event")
# 尺度化 Schoenfeld 檢定 + 殘差圖,並印出補救建議
cph.check_assumptions(d, p_value_threshold=0.05, show_plots=True)
# 分層版本:strata 內各有自己的 baseline hazard
d["k_strata"] = (d["ph_karno"] <= 70).map({True: "low", False: "high"})
CoxPHFitter().fit(d, duration_col="time", event_col="event",
strata=["k_strata"]).print_summary()lifelines 的 check_assumptions() 做的是同一件事(尺度化 Schoenfeld + 檢定),並且會直接印出建議的補救方式。
cox.zph() 的表怎麼讀
拿 survival::lung 的四變項模型跑出來——資料集 228 人,完整個案分析後進入模型的是 213 人、151 個事件:
| 變項 | HR | 95% CI | p |
|---|---|---|---|
| 年齡,每增加一歲 | 1.016 | 0.996–1.036 | 0.113 |
| 女性 vs 男性 | 0.602 | 0.428–0.848 | 0.004 |
| Karnofsky 分數,每增加一分 | 0.988 | 0.976–1.000 | 0.048 |
| 體重減輕,每公斤 | 0.997 | 0.985–1.010 | 0.657 |
cox.zph() 對這個模型的結果:
| 變項 | χ² | df | p |
|---|---|---|---|
| 年齡 | 0.93 | 1 | 0.335 |
| 性別 | 3.06 | 1 | 0.080 |
| Karnofsky 分數 | 7.38 | 1 | 0.007 |
| 體重減輕 | 0.04 | 1 | 0.842 |
| GLOBAL(整體檢定) | 10.16 | 4 | 0.038 |
這裡的虛無假設是「比例風險成立」,所以 p 小才是壞消息。 Karnofsky 分數的 p = 0.007,明確違反;整體檢定(GLOBAL)p = 0.038,也達到顯著。性別的 p = 0.080 落在邊界,年齡與體重減輕沒有問題。
figures/scripts/B3-04-ph-assumption.Rlog-log plot:類別變項的圖形版本
另一個古典檢查是把 對 作圖。它的邏輯是:如果兩組符合比例風險(風險比為 ),那麼
也就是兩條線垂直相差一個常數——平行。曲線靠攏、發散或交叉,就是假設有問題。
figures/scripts/B3-04-ph-assumption.R違反了怎麼辦
先問一個問題:這個違反是發生在你關心的變項,還是只是某個共變項?
如果只是共變項(例如你要估治療效果,違反的是年齡),那麼把它分層掉就行,代價很小。如果違反的是主要暴露或治療變項,那你就不能再報單一 HR 了——因為那個數字沒有清楚的意義。以下四條路各有代價:
路一:分層 Cox(stratified Cox)
把違反的變項從「共變項」改成「層」(strata):每一層有自己的基線風險 ,不再要求層與層之間成比例。
把 Karnofsky 分成三層之後,cox.zph() 的 GLOBAL 從 p = 0.038 變成 p = 0.441——假設救回來了。性別的 HR 變成 0.572(0.405–0.809)。
代價:分層的那個變項不再有 HR。 你買回了假設,但失去了對它的估計。所以分層適合用在「這只是個需要控制的因素,我不在乎它的效果量」的變項上。
路二:把時間切開,各段各估一個 HR
用 survSplit() 在第 180 天把每個人的追蹤時間切成兩段,讓 Karnofsky 的係數在前後兩段各自估:
| 期間 | Karnofsky 每分的 HR | 95% CI | p |
|---|---|---|---|
| 第 0–180 天 | 0.966 | 0.947–0.987 | 0.001 |
| 第 180 天之後 | 0.998 | 0.983–1.013 | 0.756 |
故事變得清楚了:Karnofsky 分數的保護效果集中在前 180 天(每高一分 HR 0.966,區間不跨 1),180 天之後未偵測到關聯(HR 0.998,區間跨過 1)。這比原本那個單一的 HR 0.988 有資訊得多——原本那個數字其實是這兩段的加權平均。
代價:切點是你選的。 先看資料再挑一個讓 p 值好看的切點,就是在做 data dredging。切點應該由臨床理由決定(例如「術後 30 天」「療程結束」),而且要在論文裡講清楚是怎麼決定的。
路三:讓係數變成時間的函數
用 tt() 直接假設 ,把 估出來。這裡的 = 0.011(SE 0.006,p = 0.062)——正號代表係數隨時間往上跑,與 Schoenfeld 圖看到的方向一致。
代價: 這個形式也是你假設的。 換成 、 會得到不同的結果。而且輸出變成兩個係數,臨床讀者不容易解讀。
路四:換一個不需要比例風險假設的效果量
前面三條路都還在修 Cox 模型。第四條路是換掉效果量本身:限制平均存活時間(restricted mean survival time, RMST)是 KM 曲線在 0 到 之間的面積——「在前 這段期間,平均活了多久」。它不需要任何比例假設,單位是「天」,而且不必要求兩組的風險比在整段追蹤裡維持固定。
RMST 現在不只是 PH 違反時的補救,在腫瘤與心衰竭試驗裡它已經是常規的次要分析。它的完整說明——面積怎麼來的、 為什麼必須事先指定、換一個 結論會變多少、以及怎麼把它講成「平均多活幾個月」——在 限制平均存活時間(RMST)。
怎麼讀報表
論文裡跟這一頁有關的訊息通常只有一句話,藏在 Statistical analysis 段落。要找的是三件事:
- 有沒有提到檢查過。 完全沒提,而主要結果是 HR,就是一個實質的方法學缺口。寫法通常是「The proportional hazards assumption was assessed using scaled Schoenfeld residuals」。
- 檢查結果怎麼處理。 「假設成立」很好;「假設違反,因此改採分層/時間分段/RMST」也很好;「假設違反但我們仍報告單一 HR」則要自己在心裡打折。
- KM 圖有沒有交叉。 這是不必看 Methods 就能做的檢查。兩條曲線在追蹤中段明確交叉、而且交叉之後持續分開,是比例風險不成立的強烈訊號——真實的風險比若固定在某個不等於 1 的值,兩條真實的存活曲線不會交叉。但要記得你看到的是估計出來的曲線:尾端只剩下少數人的時候,兩條 KM 曲線光靠抽樣波動就可能互相穿過,這種交叉不構成違反的證據。所以要對照風險人數表看交叉發生在哪一段、幅度多大,再回到 Methods 找 Schoenfeld 殘差檢定或時間交互作用的結果來確認。確認之後,那個單一 HR 就是兩段相反效果的平均,log-rank 的檢定力也會被削弱。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 跑完 Cox 完全不檢查比例風險假設 | 假設不成立時那個 HR 沒有對應到任何時點的真實風險比 |
cox.zph() p 值不顯著就宣稱「假設成立」 | 檢定力有限,尤其樣本數小時;要配合殘差圖判斷偏離的形狀與大小 |
| 大型資料庫裡 p 值一顯著就大改模型 | 樣本數夠大時臨床上可忽略的偏離也會顯著,應看 β(t) 的變化幅度 |
| 曲線交叉還是報單一 HR | 那個數字是兩段方向相反的效果的加權平均,沒有清楚的解釋 |
| 對連續變項畫 log-log plot | 必須先切組,切點是自選的,圖形跟著切點變 |
| 假設違反就改用 log-rank | log-rank 對同一個問題同樣敏感,交叉時檢定力更差 |
| 把追蹤期截短到假設成立為止 | 用資料決定分析範圍,且可能剛好丟掉治療效果真正出現的時段 |
| 看完資料才挑時間切點 | 事後選切點是 data dredging,切點應由臨床理由事先決定 |
| 用 RMST 但事後才決定 τ | τ 必須事先指定並寫進計畫書,否則等同挑選有利結果,見 RMST |
| 把分層變項的 HR 硬報出來 | 分層 Cox 不估計分層變項的效果,那個數字不存在 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B3-04-ph-assumption.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
cox.zph 的輸出每一列對應一個共變項,最後一列是 GLOBAL。要判斷 Karnofsky 這一項有沒有違反比例風險,該讀哪一個 p 值?
看答案與解析
正確答案: 0.007——Karnofsky 自己那一列,檢定的就是它的效果隨不隨時間變動
每一項要各看各的那一列,Karnofsky 是 0.007。0.080 是女性相對於男性那一列。GLOBAL 的 0.038 最容易被誤用:它顯著只說明「模型裡至少有一項的效果隨時間變動」,不指出是哪一項;而它不顯著也不保證每一項都沒問題,因為那是把各項合起來檢定,一個強烈違反可以被幾個乾淨的項稀釋掉。把 GLOBAL 的 p 值當成某一個共變項的 p 值,是這張表最常見的誤讀。
把追蹤時間切在第 180 天前後分開估計之後,Karnofsky 在兩段的風險比不一樣。那麼不分段時報出來的那一個風險比,代表什麼?
看答案與解析
正確答案: 0.988——它是兩段效果的一種加權平均,不等於任何一段
比例風險不成立時,單一的風險比不會突然變成錯誤的數字,但它不再對應任何一個時期的真實效果——它是各時期效果以事件數為權重的平均,而權重取決於這份資料的追蹤長度與事件分布,換一份追蹤更久的資料就會給出不同的值。0.966 是 Day 0 到 180,0.998 是 Day 180 之後。麻煩的地方在於,被寫進摘要、被拿去比較的正是 0.988 那一個。
同一份資料、同一個臨床問題,把 Karnofsky 換成 ECOG 之後 cox.zph 的 GLOBAL 就不顯著了。這代表什麼?
看答案與解析
正確答案: GLOBAL 變成 0.314——比例風險是否成立取決於模型怎麼設定,不是資料的固有性質
換掉的只是體能狀態的編碼方式,臨床問題完全一樣,而比例風險檢定給出相反的結論:0.314 對上原本的 0.038。所以「有沒有違反 PH」問的其實是「你這樣寫的模型有沒有違反」。0.201 是新模型裡 ECOG 自己那一列,不是 GLOBAL。這也是為什麼不該把 cox.zph 當成一個過或不過的關卡——它會隨著設定轉向,而真正該問的是效果隨時間變動到什麼程度、那個變動對結論重不重要。
用到這個方法的章節
延伸觀看
The Cox proportional hazards model explained
Survival Analysis Part 9 | Cox Proportional Hazards Model
Cox Proportional Hazard Models
【Lecture】L20 Survival Analysis (2)素材來源與授權
本頁為原創內容