Cox 比例風險模型
HR 到底是什麼比值(不是存活時間比、也不是勝算比)、baseline hazard 不用指定為什麼還算得出係數、多變項校正實際上在做什麼、ties 怎麼處理,以及信賴區間跨過 1 的時候該怎麼寫。
這個模型在解決什麼問題
Kaplan-Meier 與 log-rank 可以告訴你兩條曲線不一樣,但給不了兩件事:差多少,以及扣掉其他因素之後還差多少。
真實的臨床問題幾乎都是後者。女性肺癌病人存活比男性長——這是年齡的效果嗎?是體能狀態(performance status)的效果嗎?還是性別本身?而且分組變項如果是連續的(年齡、腫瘤大小、eGFR),log-rank 根本無從下手,你只能硬把它切成幾組,切點還是自己選的。
Cox 比例風險模型(Cox proportional hazards model)處理的就是這件事:同時放進多個變項(連續的、類別的都行),對每一個給出一個效果量,而且不需要假設存活時間服從任何特定分布。
核心概念:風險函數與 HR
先定義風險函數(hazard function):一個到 這一刻為止還沒發生事件的人,在這一瞬間發生事件的速率。
這個式子有兩個關鍵:分子裡的條件 (只算還在風險集合裡的人),以及它是個速率不是機率,可以大於 1。
Cox 模型寫成:
拆開來看是兩塊乘在一起:
- 是基線風險(baseline hazard),所有變項都等於 0 的那個人的風險隨時間怎麼跑。它可以是任何形狀——先高後低、有峰、有波動都行。
- 是一個不隨時間變的倍數,把基線風險整條往上或往下拉。
於是兩個人的風險比值是:
上下抵消掉了。這就是 hazard ratio(HR,風險比),也解釋了兩件事:為什麼 不用指定(它在比值裡消失了,這叫半參數 semi-parametric——效果的部分是參數化的,時間的部分不是),以及為什麼這叫「比例風險」(proportional hazards)——這個比值被假設在整段時間裡都是同一個數。
動手跑一次
library(survival)
data(cancer, package = "survival") # lung、colon、mgus2 都在這包裡
lung$sex_f <- factor(lung$sex, levels = c(1, 2),
labels = c("Male", "Female"))
fit <- coxph(Surv(time, status) ~ age + sex_f + ph.ecog + wt.loss,
data = lung)
summary(fit) # coef、exp(coef) = HR、95% CI、p、Concordance
# 年齡改成「每 10 歲」的對比:要在係數尺度上乘,不是把 HR 乘 10
b <- coef(fit)["age"]; se <- sqrt(vcov(fit)["age", "age"])
exp(10 * c(b, b - 1.96 * se, b + 1.96 * se))
# 大一點的真實試驗:colon 輔助化療,etype 2 = 死亡
colon_d <- subset(colon, etype == 2)
colon_d$rx <- factor(colon_d$rx, levels = c("Obs", "Lev", "Lev+5FU"))
fit2 <- coxph(Surv(time, status) ~ rx + sex + age + nodes + extent + differ,
data = colon_d)
summary(fit2)
# 校正後的存活曲線:三組都代入同一組共變項值
ref <- data.frame(rx = factor(levels(colon_d$rx), levels = levels(colon_d$rx)),
sex = 1, age = 60, nodes = 2, extent = 3, differ = 2)
plot(survfit(fit2, newdata = ref), lwd = 2,
col = c("#8c6d4a", "#4d6a8c", "#c44d4d"),
xlab = "Days", ylab = "Adjusted survival")驗證環境:R 4.6.0 + survival 3.8.6
import statsmodels.api as sm
from lifelines import CoxPHFitter
# 命名陷阱:lung 在 Rdatasets 上掛在 "cancer" 這個 help page 底下
lung = sm.datasets.get_rdataset("cancer", "survival").data
d = lung[["time", "status", "age", "sex", "ph.ecog", "wt.loss"]].dropna().copy()
d["event"] = (d["status"] == 2).astype(int)
d["female"] = (d["sex"] == 2).astype(int)
d = d.drop(columns=["status", "sex"])
cph = CoxPHFitter().fit(d, duration_col="time", event_col="event")
cph.print_summary() # coef、exp(coef)、CI、p、concordance
cph.plot() # 就是 forest plot
# colon 與 mgus2 沒有掛在 Rdatasets 上(同樣是 help page 命名的問題),
# 要在 Python 用它們,先在 R 跑一次:
# write.csv(subset(colon, etype == 2), "colon_death.csv", row.names = FALSE)lifelines 的 CoxPHFitter 預設也用 Efron 處理 ties,係數與 R 一致到小數點後多位。
figures/scripts/B3-03-cox.R怎麼讀報表
summary(coxph(...)) 印出來的東西,對應到論文 Table 的每一欄。用上面那個 lung 模型逐格看——先說清楚這個模型是用多少人跑的:資料集有 228 人,其中 15 人因為共變項有缺失被完整個案分析排除,實際進入模型的是 213 人、151 個事件。
(掉了幾個人要講出來。coxph() 不會警告你,它只是安靜地少算幾列;而缺失往往與預後有關,所以這不是無害的四捨五入。世代研究那一章有整節在談這件事。)
| 變項 | HR | 95% CI | p |
|---|---|---|---|
| 年齡,每增加一歲 | 1.01 | 0.99–1.03 | 0.165 |
| 女性 vs 男性 | 0.55 | 0.39–0.78 | < 0.001 |
| ECOG PS,每增加一分 | 1.67 | 1.31–2.14 | < 0.001 |
| 體重減輕,每公斤 | 0.99 | 0.98–1.00 | 0.176 |
逐欄怎麼講:
- HR 的方向。 大於 1 是風險升高、小於 1 是降低。女性相對男性 HR = 0.55,讀作「在同年齡、同 ECOG、同體重減輕的條件下,女性任一時刻的死亡風險約為男性的 0.55 倍」。「在其他變項相同的條件下」這句話不是客套話,是 HR 的定義的一部分,拿掉就是另一個數字了。
- 連續變項的單位。 年齡的 HR = 1.013,看起來小到沒意義——因為那是每多一歲。換成每 10 歲是 HR 1.14(0.95–1.38)。換算要在係數尺度做(),不能把 HR 乘以 10。論文沒寫單位的連續變項 HR 是無法解讀的。
- 信賴區間比 p 值有用。 年齡的區間是 0.995–1.033,跨過 1;體重減輕是 0.978–1.004,也跨過 1。
- Concordance(Harrell’s C)= 0.647。 隨機挑兩個人,模型把「先發生事件的那個」給較高風險的機率。0.5 是亂猜,1 是完美。0.647 意思是這個模型有訊號但區辨力普通——這在臨床預後模型裡很常見,也是為什麼「p 值很顯著」跟「模型能不能拿來預測個別病人」是兩件事。
- 整體模型檢定(likelihood ratio test)= 31.0,df = 4,p < 0.001。 問的是「全部係數同時為 0 嗎」,相當於迴歸裡的整體 F 檢定。
多變項校正實際上在做什麼
「校正年齡」聽起來像是把年齡的影響減掉,但模型裡發生的事更具體:在每一個事件時點,把該時點風險集合裡每個人的預測風險依各自的共變項加權,再問「實際發生事件的是不是預測風險較高的那個」。 係數就是讓這件事最一致的那組數字(技術上叫偏概似 partial likelihood)。
結果是:報出來的性別 HR,是「同年齡、同 ECOG、同體重減輕」的兩個人的比較。這帶來三個實務後果:
- 校正的變項換了,HR 就換了。 兩篇論文報不同的 HR,可能只是模型裡的變項不同,不是資料衝突。看 HR 一定要連著看那個模型放了什麼。
- 不能把中介因子(mediator)放進去。 如果治療是透過降低發炎指標起作用,把發炎指標校正進去,等於把治療的效果扣掉一部分。要決定放什麼,靠的是因果圖(DAG)不是 p 值。
- 事件數決定你能放幾個變項。 傳統經驗法則是每個變項至少 10 個事件(events per variable, EPV),近年的模擬研究認為視情況可以放寬,但方向不變:151 個事件的模型放四個變項還好,放十五個就是在配雜訊。
用一個真的臨床試驗看校正後的樣子——survival::colon 是大腸癌術後輔助化療試驗。它的原始檔是雙端點的長格式,每位病人各有復發與死亡兩列,一共 1858 列;本頁先用 etype == 2 篩出死亡端點的 929 列,再扣掉共變項缺失,模型用的是 888 人、430 例死亡:
figures/scripts/B3-03-cox.R| 變項 | HR | 95% CI | p |
|---|---|---|---|
| Levamisole vs 觀察組 | 0.913 | 0.731–1.141 | 0.423 |
| Levamisole + 5FU vs 觀察組 | 0.673 | 0.530–0.854 | 0.001 |
| 男性 | 0.967 | 0.799–1.169 | 0.728 |
| 年齡,每增加一歲 | 1.006 | 0.998–1.014 | 0.131 |
| 陽性淋巴結,每多一顆 | 1.091 | 1.071–1.111 | < 0.001 |
| 局部侵犯程度,每增加一級 | 1.617 | 1.293–2.023 | < 0.001 |
| 分化程度,每增加一級 | 1.157 | 0.949–1.409 | 0.149 |
這張表把上一段那個警告變成了具體例子:Levamisole 單用的 HR 是 0.913,區間 0.731–1.141 跨過 1 —— 本試驗未偵測到單用 Levamisole 與死亡風險的關聯。而 Levamisole + 5-FU 的 HR 是 0.673(0.530–0.854),區間完全落在 1 的左邊。歷史上這個試驗正是 5-FU 併用方案成為大腸癌標準輔助治療的依據之一。
Ties:兩個人同一天發生事件
Cox 模型的推導假設事件時間是連續的,也就是不會有兩個人在完全同一刻發生事件。真實資料當然會——時間記到天、記到月、記到年,同時發生(ties)就一定出現。R 提供三種處理方式:
| 方法 | 在做什麼 | 什麼時候用 |
|---|---|---|
| Efron | 對同時發生的事件做加權近似 | R 的預設,幾乎永遠選它 |
| Breslow | 更粗的近似,把同時事件當成各自獨立處理 | SAS 的預設;ties 多時係數會被拉向 0 |
| exact | 列舉所有可能的先後順序 | 理論上最正確,ties 極多時很慢 |
這一節換回完整的 colon 死亡端點資料(不做完整個案排除,因為 ties 的比較只牽涉時間與治療組,不需要那些有缺失的共變項),所以事件數是 452 而不是上面模型表的 430——同一頁上兩個不同的數字,是因為分析集不同,不是其中一個算錯。
時間以天為單位時,452 個死亡事件散在 409 個不同的日子,三種方法幾乎沒差:
| 方法 | Lev+5FU 的 HR | 淋巴結數的 HR |
|---|---|---|
| Efron | 0.6709 | 1.0957 |
| Breslow | 0.6710 | 1.0956 |
| exact | 0.6709 | 1.0958 |
但把同一份資料的時間粗化成「年」(452 個事件擠進 8 個時點),差距就出來了:
| 方法 | Lev+5FU 的 HR | 淋巴結數的 HR |
|---|---|---|
| Efron | 0.6747 | 1.0926 |
| Breslow | 0.6955 | 1.0853 |
| exact | 0.6570 | 1.1210 |
用之前要檢查的假設
Cox 模型有三個假設,其中最容易被忽略、後果最嚴重的是第一個:
- 比例風險 —— HR 在整段追蹤期間是同一個數。違反的話,那個單一 HR 是把不同時期相反的效果平均掉的產物,本身沒有清楚的意義。檢查方式與補救辦法在比例風險假設與 Schoenfeld residuals。
- 連續變項與 log hazard 呈線性 —— 年齡對死亡風險的作用未必是直線。檢查方式是 martingale residuals 對該變項作圖,或直接改用限制性立方樣條(restricted cubic spline)。
- 非資訊性設限 —— 與所有存活方法共用的地基,見 Censoring 與存活資料的結構。
另外,有競爭風險時,cause-specific Cox 回答的問題與 Fine-Gray 不同,這一點在競爭風險:CIF 與 Fine-Gray 裡談。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 把 HR 當成存活時間的比值 | HR 比的是瞬時風險;存活時間比是 AFT 模型給的量 |
| 把 HR 當成勝算比或相對風險 | 三者的分母與時間概念都不同;事件率低時數值接近但意義不同 |
| 只報 HR 不報基線風險或絕對風險差 | HR 0.5 在罕見事件上可能只換到極小的絕對獲益 |
| 報連續變項的 HR 卻不寫單位 | 每一歲、每十歲、每個標準差的 HR 完全不同,讀者無法解讀 |
| 把 HR 換算成「每 10 單位」時直接乘 10 | 要在係數尺度上乘再取 exp, 不等於 |
| 信賴區間跨過 1 還照樣描述效果大小 | 應寫「未偵測到顯著關聯」,並附上區間說明不確定的範圍 |
| 未達顯著就宣稱「兩者效果相同」 | 要主張相當需要非劣性/等效性設計與事先訂好的界值 |
| 事件數不多卻放進大量共變項 | 過度配適,係數與信賴區間都不可信 |
| 把治療的中介因子當共變項校正掉 | 會把想估的效果扣掉一部分;納入哪些變項靠因果圖不靠 p 值 |
| 跑完 Cox 不檢查比例風險假設 | 假設不成立時那個 HR 沒有清楚的解釋 |
| 依基線之後才知道的狀態分組 | Immortal time bias,見 B3-01 |
| 直接比較兩篇論文的 HR 大小 | 校正的變項不同,兩個 HR 定義的對比就不同 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B3-03-cox.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
這個 Cox 模型有四個共變項,summary 把風險比一列一列印出來。ECOG PS 那一列是哪一個?
看答案與解析
正確答案: 風險比 1.67——ECOG 每多一分,死亡風險上升約三分之二
ECOG PS 的分數越高代表體能狀態越差,所以它的風險比必然大於一,這一步不需要看報表就能排除另外兩個選項。0.55 其實是女性相對於男性,0.99 是體重每下降一公斤。這三個數字在 summary 裡上下相鄰,而 R 印出來的列順序取決於公式怎麼寫,不是你心裡的重要性順序——讀任何一個風險比之前先確認那一列的名字,是報表判讀裡最便宜也最常被跳過的一步。
報表的 n= 那一行,比原始資料的列數少。哪一個說法對?
看答案與解析
正確答案: 模型用了 213 人——任何一個共變項有缺值的整列都被丟掉了
coxph 預設做完整病例分析:只要任一共變項有缺值,整列就不進模型,所以 228 列裡只有 213 進去。這件事在輸出上只是係數表下面的一行 n= 與 number of events=,很容易滑過去;151 是事件數,不是人數。少掉的那十五個人不是隨機消失的,通常正是狀況最差、量測最不完整的那一群,所以那不只是樣本變小,而是樣本換了一批人。
同一個模型的 likelihood ratio test,它的自由度是由什麼決定的?
看答案與解析
正確答案: 自由度是 4,等於模型裡估計的共變項個數
likelihood ratio test 比的是「有這些共變項」與「什麼都沒有」兩個模型,自由度就是兩者相差的參數個數,這裡是四個共變項。15 是被缺值丟掉的列數,151 是事件數——報表上這些整數上下挨著,意思卻毫不相干。事件數確實會影響這個檢定有多少檢定力,但它不是自由度;而這個檢定回答的是「這四項合起來有沒有解釋力」,不是任何單一項顯不顯著。
用到這個方法的章節
延伸觀看
Hazard Ratios – Best explanation for beginners
Cox Regression [Cox Proportional Hazards]
COX REGRESSION and HAZARD RATIOS
存活分析(Survival Analysis)第二部分素材來源與授權
本頁為原創內容