nomogram(列線圖)
nomogram 是華人臨床論文的招牌圖,也是最常被誤讀成「一種模型」的東西。它不是模型,它是把已經配好的模型的線性預測值換算成分數、再畫成一把尺——沒有多出任何資訊。這一頁用同一位病人的兩種算法、整個開發世代的仿射恆等式,證明它真的只是一把尺,然後把該問的問題交還給內部驗證、外部驗證與校準。
nomogram 不是一種模型
在腫瘤科與外科的臨床論文裡,nomogram 幾乎是格式的一部分:模型配完、C-index 報完, 最後一張圖就是那把橫著排、上面掛滿刻度的尺。它太常出現,以至於句子會寫成 「我們建立了一個 nomogram 來預測五年復發」——好像 nomogram 是一種模型。
它不是模型。 它是把一個已經配好的迴歸模型的線性預測值, 換算成 0 到 100 分的刻度,再畫出來。沒有任何新的資訊被加進去, 也沒有任何參數在畫圖的時候被估計。它是一個手算工具:讓不想開電腦的人, 用一支筆和一把直尺,得到跟程式一樣的數字。
所以「這張 nomogram 好不好」這個問題本身是壞的。該問的是它背後那個模型 有沒有做過內部驗證、 外部驗證與 校準。這一頁的後半會把這三個問題, 接回本站在同一個模型上已經量出來的答案。
這張尺畫的是什麼
這一頁刻意用與校準那一頁完全同一個模型:
在 survival::rotterdam(2982 位病人、
1713 個事件)上配一個 Cox 模型,四個預測變項,
時間視野 5 年;外部驗證世代是 survival::gbsg
(686 位病人、299 個事件)。
用同一個模型是這一頁的論證需要:如果 nomogram 真的只是換一種畫法, 那它就不該改變校準那一頁量到的任何一個數字。
figures/scripts/B5-09-nomogram.R讀它的動作有三步:
- 每個變項在自己那條軸上找到病人的值,往上垂直對到分數軸,讀一個分數。
- 四個分數相加,在總分軸上找到那個位置。
- 從總分軸往下垂直對到最底下的風險軸,讀出預測風險。
順著這把尺走一次
拿一位假想病人:年齡 55、腫瘤 35 mm、
4 顆陽性淋巴結、grade 3。這位病人是刻意挑在開發世代中段的,
理由就是上一段那件事(腳本裡的 selection_rule 記著同一句話)。
逐項把分數讀出來——不是用係數重算,是在圖上那幾條軸上做線性內插, 也就是讀者拿尺會做的同一件事:
| 變項 | 這位病人的值 | 在圖上讀到的分數 |
|---|---|---|
| 診斷時年齡(歲) | 55 | 6.59 |
| 腫瘤大小(mm) | 35 | 11.64 |
| 陽性淋巴結數 | 4 | 11.43 |
| Grade 3 組織學(1 = 是) | 1 | 13.14 |
| 總分 | 42.80 |
總分 42.80 分換回線性預測值是 0.280401, 再過一次基準存活函數,得到五年風險 52.2%。
另一條路完全不碰那張圖:把四個共變項值直接乘上係數、加起來,得到線性預測值 0.280401,同樣過一次基準存活函數,得到 52.2%。
figures/scripts/B5-09-nomogram.R不只這一位病人:整個開發世代
一位病人吻合,可能是挑出來的。所以產這一頁的腳本把開發世代的每一位病人 都跑了一次:對每個人在四條軸上讀分數、加總,再跟他自己的線性預測值對照。
figures/scripts/B5-09-nomogram.R2982 個點,最大殘差 4.3e-14,
是 1.000000。總分是線性預測值的仿射變換
(先平移、再乘上一個常數),沒有別的。寫成式子:
是線性預測值, 是總分為 0 時的線性預測值, 是每一單位線性預測值值多少分。
在這個模型上 是 39.57 分, 是 -0.801。
( 是負的,因為 rms 給的線性預測值以共變項的平均值為原點:
總分 0 的那位病人在每一條軸上都取最小值,當然低於世代平均。)
分數的刻度是怎麼定出來的
既然是換單位,就得有人決定換算率。rms::nomogram 的規則是:
先算每個變項在自己那條軸的刻度範圍內能讓線性預測值移動多少,
把移動最多的那個變項的軸定成滿分 100,其餘變項按同一個比例分。
刻度範圍不等於觀察範圍——這個模型上,陽性淋巴結數 的刻度取到 35,
而世代裡實際看到的最大值是 34,換算率算的是前者。
在這個模型上,跨度最大的是陽性淋巴結數那一條軸——它的係數乘上軸的跨度是四個變項裡最大的, 所以它獨佔滿分,其他三條軸都比它短:Grade 3 組織學 最短,只有 13.14 分, 年齡那一條 14.03 分緊接在後——後者短到產圖腳本必須自己指定刻度, 否則預設的刻度會擠成一團。 換算率因此定在每單位線性預測值 39.57 分。
還有一個實作上的坑,跟臨床解讀無關但會讓頁面說謊:畫圖的套件與報表的套件必須是同一個配適。
nomogram() 只認得 rms::cph 的模型物件,
而多數人在正文報的係數來自 survival::coxph。
cph 的收斂容忍度預設比較鬆,兩邊的係數只會吻合到小數點後四、五位——
足以讓「這是同一個模型」這句話變成不完全正確的。
產這一頁的腳本把容忍度收緊,並且用 stopifnot() 擋住:
| 變項 | rms::cph | survival::coxph | 差距 |
|---|---|---|---|
| 診斷時年齡(歲) | 0.005371 | 0.005371 | 2.3e-17 |
| 腫瘤大小(mm) | 0.014708 | 0.014708 | 1.2e-17 |
| 陽性淋巴結數 | 0.072202 | 0.072202 | 6.9e-17 |
| Grade 3 組織學(1 = 是) | 0.332017 | 0.332017 | 4.6e-15 |
四個係數的最大差距是 4.6e-15,
低於腳本設定的門檻。如果哪天某次改動讓兩邊分家,腳本會停下來,而不是安靜地出貨一張
「不是正文那個模型」的圖。
跟 Fagan nomogram 不是同一個東西
診斷那一頁也有一張叫 nomogram 的圖。 兩者共用「用幾何取代算術」這個古老的想法,但用途、輸入與輸出全部不同:
| Fagan nomogram | 這一頁的 nomogram | |
|---|---|---|
| 屬於哪一類問題 | 診斷檢驗的機率更新 | 預測模型的風險換算 |
| 輸入 | 檢驗前機率、概似比 | 一位病人的所有預測變項值 |
| 做的運算 | 勝算乘上概似比 | 線性預測值換算成分數再換成風險 |
| 背後要先有什麼 | 一份檢驗的敏感度與特異度 | 一個配好的迴歸模型 |
| 怎麼用 | 兩點連一條直線,延長讀第三軸 | 每條軸垂直往上讀分數,加總後往下讀風險 |
| 輸出 | 檢驗後機率 | 預測風險 |
最實際的差別在動作:Fagan 的圖要你畫一條斜的直線穿過三條軸; 這裡的圖只要求垂直對齊,然後做加法。看到有人在預測模型的 nomogram 上 拿尺連斜線,那是把兩張圖搞混了。
那該問這張圖什麼
既然 nomogram 沒有增加資訊,評價它就等於評價它背後那個模型。同一個模型,本站量過三件事:
| 問題 | 這個模型的答案 | 在哪一頁 |
|---|---|---|
| 排序能力(開發世代) | C-index 0.666 | 內部驗證 |
| 排序能力(外部世代) | C-index 0.642 | 外部驗證 |
| 數值對不對 | 校準斜率 0.691(95% CI 0.539–0.842) | 校準 |
鑑別力從開發到外部掉了一點,這是預期中的。真正該停下來的是最後一列: 校準斜率明顯低於 1,而且 95% 信賴區間整段落在 1 以下。
斜率小於 1 的意思是預測值過度離散:
高風險的人被推得太高、低風險的人被壓得太低。
放回這張 nomogram 上,就是同一句話的圖形版——這把尺的兩端都太長。
一位在圖上落到高分端的病人,讀出來的風險比他在
survival::gbsg 這個世代真正的風險高;低分端反過來。
動手跑一次
library(survival); library(rms)
data(cancer, package = "survival")
dev <- rotterdam
dev$rfs_time <- pmin(dev$rtime, dev$dtime)
dev$rfs_event <- as.integer(dev$recur == 1 | dev$death == 1)
dev$size_mm <- c("<=20" = 15, "20-50" = 35, ">50" = 60)[as.character(dev$size)]
dev$grade3 <- as.integer(dev$grade >= 3)
# 標籤要在 cph() 之前掛上去,否則圖上留下裸變項名,而且不會報錯
label(dev$age) <- "Age at diagnosis (years)"
label(dev$size_mm) <- "Tumour size (mm)"
label(dev$nodes) <- "Positive nodes (count)"
label(dev$grade3) <- "Grade 3 histology (1 = yes)"
dd <- datadist(dev); options(datadist = "dd")
# eps 預設是 1e-4,係數只會跟 coxph 吻合到 1e-5 量級
fit <- cph(Surv(rfs_time, rfs_event) ~ age + size_mm + nodes + grade3,
data = dev, surv = TRUE, x = TRUE, y = TRUE, eps = 1e-9, tol = 1e-12)
max(abs(coef(fit) - coef(coxph(Surv(rfs_time, rfs_event) ~
age + size_mm + nodes + grade3, data = dev)))) # 應該是 1e-15 量級
H <- 5 * 365.25
surv5 <- Survival(fit)
risk5 <- function(lp) 1 - surv5(H, lp)
nom <- nomogram(fit, fun = risk5, funlabel = "5-year risk")
plot(nom, xfrac = 0.34)
# 換算率就存在 nomogram 物件的 attribute 裡
info <- attr(nom, "info")
info$sc # 每一單位線性預測值幾分
info$Intercept # 總分為 0 時的線性預測值
# 一位病人,兩條路
patient <- data.frame(age = 55, size_mm = 35, nodes = 4, grade3 = 1)
lp_direct <- as.numeric(predict(fit, newdata = patient, type = "lp"))
# ⚠️ 掛了標籤之後,nom 的元素是用「標籤」命名的,用變項名查會拿到 NULL
ax <- nom[["Positive nodes (count)"]]
p_node <- approx(ax$nodes, ax$points, xout = patient$nodes)$y
# 把四條軸的分數都讀出來加總之後:
# lp_from_points <- total_points / info$sc + info$Intercept
# risk5(lp_from_points) 與 risk5(lp_direct) 會相同到浮點精度驗證環境:R 4.6.0 + survival 3.8.6 + rms 8.1.1。兩個容易靜默出錯的地方:label() 必須在 cph() 之前設好(plot.nomogram 讀的是配適物件裡存的標籤,事後再設會安靜地留下裸變項名);而一旦設了標籤,nomogram() 就改用「標籤」當 list 元素的名字,用變項名去查會拿到 NULL,再取 [["points"]] 還是 NULL 而不是報錯。
import numpy as np, pandas as pd
from lifelines import CoxPHFitter
# 與 B5-05 相同的世代調和與模型配適。攤開寫,這一段才自己跑得動。
RD = "https://vincentarelbundock.github.io/Rdatasets/csv/"
rot = pd.read_csv(RD + "survival/rotterdam.csv")
rot["rfs_time"] = rot[["rtime", "dtime"]].min(axis=1)
rot["rfs_event"] = ((rot["recur"] == 1) | (rot["death"] == 1)).astype(int)
rot["size_mm"] = rot["size"].map({"<=20": 15, "20-50": 35, ">50": 60})
rot["grade3"] = (rot["grade"] >= 3).astype(int)
dev = rot
# rms::nomogram 沒有 Python 等價品。但那張圖的全部內容就是這幾行。
v = ["age", "size_mm", "nodes", "grade3"]
fit = CoxPHFitter().fit(dev[v + ["rfs_time", "rfs_event"]], "rfs_time", "rfs_event")
beta = fit.params_[v].to_numpy()
lo, hi = dev[v].min().to_numpy(), dev[v].max().to_numpy()
span = np.abs(beta * (hi - lo)) # 每條軸能讓線性預測值移動多少
scale = 100 / span.max() # 跨度最大的那條軸值滿分
def points(x): # 一位病人的逐項分數
return beta * (np.asarray(x, float) - lo) * scale
p = points([55, 35, 4, 1])
total = p.sum()
lp = total / scale + float(beta @ lo) # 換回線性預測值(未中心化)
# 對照:直接算。這裡兩者都在未中心化的刻度上,所以應該完全相等
lp_direct = float(beta @ np.array([55, 35, 4, 1]))
print(p, total, lp, lp_direct) # lp 與 lp_direct 應相同
# 畫尺:每條軸就是一條線段加一組刻度,用 matplotlib 的 hlines + text 即可Python 沒有 rms::nomogram 的等價品:lifelines、scikit-survival 都不畫這張圖,也沒有第三方套件補上。但這不構成障礙——這張圖的內容就是下面那幾行算術,畫出來只是把幾條線段擺到 matplotlib 上。注意 lifelines 的 predict_log_partial_hazard 與 rms 一樣是中心化過的,兩邊比對時基準要一致。
讀論文看到 nomogram 時要問的四件事
- 有沒有外部驗證? 只在開發世代畫一張 nomogram,等於把一個沒驗過的模型排版得很好看。
- 有沒有校準曲線? 這張圖給的是絕對風險,而絕對風險準不準是校準的問題,不是 C-index 的問題。
- 分數是拿來做什麼的? 如果論文用分數切出「高/中/低風險組」,那個切點是怎麼定的、 有沒有在別的資料上重現過?分數本身沒有跨研究的意義。
- 它跟現有的分期系統比,多買到了什麼? 這要看 決策曲線或淨重分類,不是看圖好不好看。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 把 nomogram 當成一種模型或一種方法 | 它是既有模型的重新排版,資訊量等於線性預測值那一個數字 |
| 說「我們用 nomogram 提升了預測表現」 | 換單位不會改變任何一個人的排序,也不會改變任何一個人的預測風險 |
| 只畫圖、不報係數與基準存活率 | 沒有這兩樣東西,別人無法在自己的資料上重現這個模型 |
| 拿別篇論文的分數跟自己的分數比 | 換算率由各自資料裡跨度最大的變項決定,兩套分數不同尺 |
| 用「某變項分數最高」宣稱它最重要 | 那反映的是這份資料裡的變異範圍,不是效應大小,也不是臨床重要性 |
| 在開發世代上畫完就宣告可以臨床使用 | 缺的是外部驗證與校準,圖再精緻都補不上 |
| 把風險軸當成等距刻度用尺量距離 | 總分到風險是非線性轉換,高風險端的刻度被壓縮 |
| 拿尺在預測模型的 nomogram 上連斜線 | 那是 Fagan nomogram 的讀法,這裡要垂直對齊後相加 |
| 用 rms::cph 畫圖、用 survival::coxph 報係數而不比對 | 預設收斂容忍度不同,兩邊會小幅分家,圖與正文說的不是同一個模型 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B5-09-nomogram.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
把開發世代每一位病人的總分對他自己的線性預測值畫出來,得到的是一條完美的直線。這代表什麼?
看答案與解析
正確答案: 1.000000——總分是線性預測值的仿射變換,這張圖沒有替任何一位病人新增資訊
1.000000 的 R 平方在別的地方會是警訊,通常代表有人不小心把結果變項放進了預測變項;在這裡它就是這一頁的整個論點——總分等於線性預測值先平移、再乘上一個常數,就像攝氏換華氏。所以罕見地精準這個讀法問錯了問題:0.000000 這個最大殘差不是模型準,是恆等式留下的浮點捨入。0.666129 是同一個模型在開發世代的 C-index,那是模型的排序能力,跟這張散布圖無關。nomogram 換的是單位,不是資訊量,任何因為做了 nomogram 所以模型更臨床可用的說法,在這張圖前面都站不住。
在這張 nomogram 上,陽性淋巴結數那條軸拿到滿分,其餘三條按同一個比例分。這句話說明了什麼?
看答案與解析
正確答案: 100.0 分的意思是:在自己那條軸的刻度範圍內,這個變項讓線性預測值移動得最多,換算率就是照它定的
分數的換算率是這樣定的:先算每個變項在自己那條軸的刻度範圍內能讓線性預測值移動多少,移動最多的那一條定成滿分,其餘按同一個比例分。所以 100.0 描述的是係數乘上軸的跨度在這份資料裡最大,不是臨床重要性——換一份資料、換一組共變項,甚至只是把某條軸的刻度畫寬一點,同一位病人的分數就變了。26.2 與 13.1 同理,它們是這份資料裡的變異範圍留下的痕跡,不是變項本身的重量,所以跟另一篇論文的分數並排比較沒有意義。會跨研究比較的東西是風險本身。
這一頁刻意挑一位五年風險落在開發世代中段的病人來示範兩條算法一致,而不是挑一位風險特別高的病人。這個選擇為什麼會影響示範的說服力?
看答案與解析
正確答案: 會影響,因為最底下那條風險軸有一段是平的:這位病人的五年風險 0.522 沒有落在那一段
從總分換到風險要經過一個非線性轉換,所以同樣是十分的差距,站在軸上不同的位置換得到的風險差不一樣大;而在風險已經逼近上限的那一段,再加分數也只換得到很小的差距,那就是風險軸的平坦段。示範的說服力就卡在這裡:挑一位落在平坦段的病人,兩條算法就算走的是兩個明顯不同的線性預測值,最後對到的風險也會看起來一樣,吻合因此變得廉價。所以這位病人的五年風險 0.522 是刻意挑在中段的,腳本裡也記著同一句話。39.572 分對一個線性預測值單位確實是固定的換算率,可是那是總分與線性預測值之間的關係,風險軸是疊在它上面的另一層,換算率固定不會讓那一層跟著等距。42.795 是他的總分;總分軸本身從頭到尾都是等距的,兩端一樣讀得準,非線性的是最底下那一條。
決定分數換算率時,套件用的是每條軸的刻度範圍,不是世代裡實際觀察到的範圍。在這個模型上兩者差在哪裡?
看答案與解析
正確答案: 淋巴結軸的刻度取到 35 顆,比實際看到的最大值多一顆,而換算率算的是刻度範圍這一側
換算率算的是刻度範圍,不是觀察範圍。這個模型上三條軸的兩者剛好相同,只有淋巴結那一條不同:刻度取到 35,世代裡實際看到的最大值是 34。差一顆看起來微不足道,可是它就是換算率的一部分——也就是說這條軸值幾分,取決於畫圖的人把刻度畫到哪裡;90 歲那條軸如果多畫十歲,年齡的分數也會跟著變。這正是分數不能跨論文比較的機械原因。
這一頁刻意用與校準那一頁完全相同的模型。如果 nomogram 真的只是換一種畫法,哪一件事必須成立?
看答案與解析
正確答案: 外部世代的校準斜率仍然是 0.691,與校準那一頁一模一樣——換畫法不該改變已經量過的數字
nomogram 是那個已經配好的 Cox 模型的重新排版,所以它不能改動任何一個從那個模型算出來的數字:外部世代的校準斜率仍然是 0.691,與校準那一頁報的同一個值,這正是同一個模型這句話的可檢驗版本。0.666 與 0.642 分別是開發世代與外部世代的 C-index,兩者都不會因為換一張圖而移動——好不好用是使用者介面的事,模型的排序能力與刻度在畫圖之前就決定了。實作上真正會讓這件事出錯的,是畫圖與報表用了兩個不同的配適:畫 nomogram 的函式只認得 rms 的模型物件,而多數人正文報的係數來自另一個函式,兩邊的收斂容忍度不同,係數就只會吻合到小數點後幾位。
用到這個方法的章節
素材來源與授權
本頁為原創內容