Tuyến Trần, MD
Lập trình & Phân tích

Vẽ đường ROC bằng R: AUC cao vẫn chưa đủ

Vẽ đường ROC bằng R để tính AUC và khoảng tin cậy, nhưng phải kiểm tra hướng điểm số, cách chọn ngưỡng và giá trị sử dụng lâm sàng.

Một xét nghiệm có AUC 0,90 nghe rất thuyết phục. Nhưng nếu ngưỡng được chọn từ chính bộ dữ liệu dùng để đánh giá, hiệu năng khi đưa vào bệnh viện khác có thể thấp hơn. Vẽ đường ROC bằng R là thao tác kỹ thuật tương đối ngắn; phần khó là xác định đường cong đang trả lời câu hỏi lâm sàng nào.

Ví dụ dưới đây tạo dữ liệu giả lập, tính AUC và xuất hình bằng R. Không có số liệu người bệnh thật trong ví dụ này; AUC thu được chỉ phản ánh dữ liệu đã mô phỏng.

Vẽ đường ROC bằng R cần dữ liệu thế nào?

Để xây dựng đường ROC thông thường cho kết quả nhị phân, bạn cần một biến kết quả có hai trạng thái và một điểm số liên tục hoặc có thứ tự. Ví dụ: có hay không có biến chứng trong khoảng theo dõi đã định trước, cùng một chỉ số xét nghiệm đo trước khi kết quả xảy ra. Phải xác định rõ giá trị nào là biến cố cần phát hiện.

Nếu kết quả là thời gian đến biến cố và có người bị kiểm duyệt theo dõi, không nên đơn giản ép thành có hoặc không có biến cố rồi bỏ qua thời gian. Bài toán đó có thể cần phương pháp ROC theo thời gian hoặc phân tích sống còn. Tương tự, nghiên cứu nhiều nhóm kết quả không thể dùng nguyên cách diễn giải của ROC nhị phân.

Một bộ dữ liệu tối thiểu nên có ba cột: mã quan sát, kết quả và điểm số. Trước khi tính, kiểm tra trùng người bệnh, giá trị thiếu, đơn vị đo và thời điểm lấy mẫu. Đừng để một người có nhiều lần đo xuất hiện như nhiều người độc lập nếu phương pháp phân tích không xử lý cấu trúc lặp lại.

Đoạn mã sau tạo dữ liệu giả lập, có chủ đích làm điểm số của nhóm có biến cố cao hơn trung bình. Số quan sát và các phân phối ở đây chỉ để minh họa cách dùng hàm.

set.seed(2026)

n0 <- 100
n1 <- 100
dat <- data.frame(
  id = seq_len(n0 + n1),
  event = c(rep(0L, n0), rep(1L, n1)),
  score = c(rnorm(n0, mean = 0, sd = 1),
            rnorm(n1, mean = 0.8, sd = 1))
)

stopifnot(
  nrow(dat) == 200,
  all(dat$event %in% c(0L, 1L)),
  !anyNA(dat[, c("event", "score")])
)
table(dat$event)

Ở dữ liệu thật, thay phần tạo dữ liệu bằng bước đọc tệp và làm sạch đã được kiểm tra. Nên giữ bảng dữ liệu gốc riêng và lưu các bước biến đổi bằng mã để có thể tái lập. Quy trình phân tích từ tệp CSV đến bảng kết quả giải thích cách tổ chức phần đó.

Tính AUC và khoảng tin cậy bằng pROC ra sao?

Gói pROC có hàm roc() để xây dựng đường ROC, auc() để lấy diện tích dưới đường cong và ci.auc() để tính khoảng tin cậy. Theo tài liệu chính thức của pROC, gói này cũng hỗ trợ so sánh đường ROC và lấy tọa độ các ngưỡng.

Sau khi cài gói một lần trên máy của bạn, có thể chạy đoạn sau. Trong ví dụ, 0 là không có biến cố, 1 là có biến cố, và điểm số càng cao thì càng nghi ngờ có biến cố.

# Chỉ cần chạy một lần nếu máy chưa có pROC:
# install.packages("pROC")

library(pROC)

roc_obj <- pROC::roc(
  response = dat$event,
  predictor = dat$score,
  levels = c(0, 1),
  direction = "<",
  quiet = TRUE
)

auc_value <- as.numeric(pROC::auc(roc_obj))
auc_ci <- as.numeric(pROC::ci.auc(roc_obj, conf.level = 0.95))

data.frame(
  AUC = round(auc_value, 3),
  CI_low = round(auc_ci[1], 3),
  CI_high = round(auc_ci[3], 3)
)

Tham số levels = c(0, 1) khai báo nhóm chứng và nhóm biến cố. direction = "<" xác định quy ước điểm số cao hơn gợi ý biến cố; bạn phải chọn theo ý nghĩa lâm sàng, không đổi hướng chỉ để AUC đẹp hơn. Với dữ liệu mà điểm số thấp hơn mới báo nguy cơ, cần xem lại chiều so sánh và ghi rõ trong phương pháp.

Đường ROC biểu diễn độ nhạy trên trục đứng và tỷ lệ dương tính giả, tức 1 - độ đặc hiệu, trên trục ngang. AUC tóm tắt khả năng phân biệt hai nhóm theo thứ tự điểm số. Nó không cho biết nguy cơ tuyệt đối của từng người bệnh và không tự chứng minh một xét nghiệm đủ tốt để đưa vào quyết định điều trị.

Khoảng tin cậy 95% mô tả độ bất định của ước lượng theo phương pháp đã dùng. Một AUC có giá trị điểm khá cao nhưng khoảng tin cậy rộng vẫn cần diễn giải thận trọng, nhất là khi số biến cố ít. Đừng chỉ trích một con số sau dấu phẩy và bỏ mất thông tin về cỡ mẫu.

Nếu bạn so sánh hai chỉ số trên cùng người bệnh, các đường ROC là dữ liệu ghép cặp. Khi đó cần dùng phép so sánh phù hợp, chẳng hạn roc.test() với đối tượng ghép cặp, thay vì coi hai AUC là hai mẫu độc lập. Sự khác biệt có ý nghĩa thống kê cũng chưa chắc tạo ra lợi ích lâm sàng.

Xuất hình ROC cho bài báo bằng R như thế nào?

Hình dùng trong bản thảo cần có nhãn trục, đường tham chiếu và chú thích AUC đủ rõ khi thu nhỏ. Không cần tô nhiều màu hay đặt quá nhiều con số lên đường cong. Một hình sạch cho người đọc nhìn được hình dạng đường ROC và phạm vi so sánh.

Đoạn mã dưới đây dùng đồ họa cơ bản của R, nên không cần thêm gói vẽ ngoài pROC. Tệp PNG được lưu ở độ phân giải 300 dpi. Trước khi nộp, kiểm tra yêu cầu riêng của tạp chí về định dạng, kích thước và cách ghi chú hình.

png(
  filename = "roc_example.png",
  width = 1800,
  height = 1800,
  res = 300
)

plot(
  roc_obj,
  legacy.axes = TRUE,
  xlab = "1 - Specificity",
  ylab = "Sensitivity",
  main = "ROC curve (synthetic data)",
  lwd = 2,
  identity = TRUE,
  identity.col = "grey60",
  identity.lty = 2
)
legend(
  "bottomright",
  legend = sprintf(
    "AUC %.3f (95%% CI %.3f to %.3f)",
    auc_value, auc_ci[1], auc_ci[3]
  ),
  bty = "n"
)
dev.off()

Có hai chi tiết dễ bị bỏ qua. Thứ nhất, nhiều đồ thị ROC mặc định dùng trục độ đặc hiệu giảm từ trái sang phải. legacy.axes = TRUE làm cách hiển thị gần với quy ước tỷ lệ dương tính giả tăng từ trái sang phải; bạn vẫn cần đọc đúng nhãn trục khi so sánh hình từ các phần mềm khác.

Thứ hai, đường chéo chỉ biểu diễn mức phân biệt tương đương lựa chọn ngẫu nhiên trong bối cảnh ROC nhị phân thông thường. Nó không phải một ngưỡng chẩn đoán, và không thể dùng để quyết định bệnh nhân nào cần can thiệp.

Nếu tạp chí yêu cầu hình véc-tơ, có thể thay png() bằng pdf() với kích thước phù hợp. Đừng chụp màn hình cửa sổ đồ thị để đưa vào bài báo vì chữ và nét có thể mờ khi dàn trang.

Chọn ngưỡng chẩn đoán từ ROC có đáng tin không?

Một ngưỡng là điểm mà từ đó bạn gọi kết quả xét nghiệm là dương tính. Với mỗi ngưỡng, độ nhạy và độ đặc hiệu thay đổi theo hướng đối nghịch trong nhiều tình huống. Ngưỡng phù hợp phụ thuộc hậu quả bỏ sót bệnh, tác hại của dương tính giả và bối cảnh sử dụng xét nghiệm.

Chỉ số Youden thường được định nghĩa là độ nhạy + độ đặc hiệu - 1. Nó tìm một điểm cân bằng theo tiêu chí toán học, nhưng không tự phản ánh chi phí lâm sàng của từng loại sai lầm. Nếu bỏ sót bệnh nặng nguy hiểm hơn nhiều so với làm thêm một xét nghiệm, ngưỡng ưu tiên độ nhạy có thể hợp lý hơn.

Trong pROC, hàm coords() có thể trả về ngưỡng theo Youden cùng độ nhạy và độ đặc hiệu. Tài liệu hàm tọa độ của pROC lưu ý có thể tồn tại nhiều ngưỡng cùng đạt tiêu chí tốt nhất. Không nên tự ý chọn một ngưỡng duy nhất mà không kiểm tra kết quả.

cutoff_candidates <- pROC::coords(
  roc_obj,
  x = "best",
  best.method = "youden",
  ret = c("threshold", "sensitivity", "specificity"),
  transpose = FALSE
)
print(cutoff_candidates)

Đoạn mã trả về ngưỡng được tối ưu trên chính bộ dữ liệu giả lập. Trong nghiên cứu thật, nếu bạn chọn ngưỡng và đánh giá độ nhạy, độ đặc hiệu trên cùng tập dữ liệu, kết quả có thể lạc quan. Hãy xác định ngưỡng trước nếu có cơ sở lâm sàng, hoặc đánh giá lại trên dữ liệu độc lập.

Khi báo cáo một ngưỡng, cần ghi rõ đơn vị đo, chiều bất thường của chỉ số, cách xử lý giá trị bằng ngưỡng và lý do chọn ngưỡng. Nếu có nhiều ngưỡng ứng viên, nên mô tả tiêu chí lựa chọn thay vì chỉ công bố ngưỡng cho kết quả đẹp nhất.

Vì sao AUC cao vẫn chưa đủ cho quyết định lâm sàng?

Giả sử một chỉ số phân biệt khá tốt người có và không có biến cố. Bạn vẫn cần biết tại ngưỡng dự kiến sử dụng, bao nhiêu người bệnh thật bị bỏ sót và bao nhiêu người không có bệnh phải làm thêm xét nghiệm. AUC tóm tắt nhiều ngưỡng, còn bác sĩ thường phải hành động tại một hoặc vài ngưỡng cụ thể.

Giá trị tiên đoán dương và âm còn phụ thuộc tỷ lệ bệnh trong quần thể sử dụng. Một xét nghiệm có cùng độ nhạy và độ đặc hiệu có thể tạo tỷ lệ kết quả dương tính đúng rất khác nhau giữa phòng khám sàng lọc và đơn vị chuyên sâu. Vì vậy, không thể bê nguyên hiệu quả của một mẫu bệnh viện sang cộng đồng mà không đánh giá lại.

Nếu điểm số là xác suất dự đoán, bạn còn phải kiểm tra độ hiệu chuẩn: người được dự đoán nguy cơ 20% có thực sự gặp biến cố xấp xỉ mức đó trong quần thể đánh giá hay không? AUC cao không bảo đảm dự đoán xác suất chính xác. Việc này đặc biệt quan trọng khi một mô hình được dùng để tư vấn nguy cơ cá nhân.

TRIPOD+AI phân biệt khả năng phân biệt và độ hiệu chuẩn khi báo cáo mô hình dự đoán lâm sàng. Hướng dẫn cũng nhấn mạnh tập đánh giá cần tách biệt với dữ liệu dùng để phát triển, điều chỉnh hoặc lựa chọn mô hình. Nếu bạn đang đánh giá mô hình dự đoán, chỉ đưa hình ROC là chưa đủ.

Một vấn đề khác là phổ bệnh và cách chọn mẫu. Nếu nhóm bệnh rất nặng còn nhóm chứng hoàn toàn khỏe mạnh, việc phân biệt có thể dễ hơn tình huống lâm sàng thực, nơi bệnh nhân có biểu hiện chồng lấp. Hãy mô tả rõ quần thể, tiêu chí tuyển mẫu và môi trường áp dụng dự kiến.

Cần phân biệt phân tích khám phá với xác nhận. Một ROC vẽ sau khi thử hàng chục chỉ số trên cùng bộ dữ liệu là một kết quả khám phá; lựa chọn chỉ số tốt nhất rồi trình bày như đã xác nhận độc lập sẽ làm quá mức bằng chứng. Kế hoạch phân tích và cách xử lý thử nhiều phép so sánh cần được mô tả trung thực.

Báo cáo ROC trong Methods và Results thế nào cho đủ?

Trong Methods, trước hết hãy nêu biến kết quả, thời điểm đo điểm số, cách xử lý dữ liệu thiếu và quy ước chiều điểm số. Tiếp theo ghi phần mềm, gói phân tích, cách tính AUC, khoảng tin cậy và tiêu chí xác định ngưỡng. Nếu có tập kiểm định độc lập, nêu rõ cách tách mẫu và bảo đảm không rò rỉ thông tin.

Trong Results, báo số người tham gia, số người có biến cố và số người không có biến cố. Sau đó trình bày AUC cùng khoảng tin cậy 95%. Nếu công bố ngưỡng, báo thêm độ nhạy, độ đặc hiệu và khoảng tin cậy phù hợp tại ngưỡng ấy, không chỉ nêu một giá trị ngưỡng đơn lẻ.

Có thể dùng cấu trúc diễn đạt sau, nhưng phải thay toàn bộ dấu ngoặc bằng số đã kiểm chứng từ dữ liệu thật:

Điểm số [tên chỉ số] được đánh giá khả năng phân biệt [kết quả] trong [quần thể]. AUC là [giá trị] (KTC 95% [cận dưới] đến [cận trên]). Tại ngưỡng [giá trị và đơn vị], độ nhạy là [x]% và độ đặc hiệu là [y]%. Ngưỡng được [định trước / xác định từ dữ liệu khám phá / kiểm định độc lập].

Nếu bài chỉ có phân tích khám phá, nói rõ điều đó trong Discussion. Đừng gọi một ngưỡng là “tối ưu cho thực hành” chỉ vì nó tối đa hóa Youden trong một mẫu hồi cứu. Cần cân nhắc hiệu năng bên ngoài mẫu, tác hại và lợi ích của quyết định theo ngưỡng.

Để giữ phân tích có thể tái lập, lưu mã R, phiên bản gói và bảng kết quả số cùng với hình. Bài về Quarto và R Markdown giải thích cách tạo báo cáo có thể chạy lại khi người phản biện yêu cầu kiểm tra phân tích. Một tệp hình đẹp không thay thế được quy trình tái lập.

Trước khi nộp, có thể kiểm bài ROC theo năm câu hỏi: kết quả được định nghĩa thế nào; điểm số đo trước hay sau biến cố; chiều điểm số có đúng; ngưỡng được chọn từ đâu; và đã đánh giá ngoài dữ liệu phát triển chưa. Nếu một câu chưa trả lời được, cần sửa phương pháp hoặc thu hẹp kết luận trước khi chỉnh màu biểu đồ.

Khóa Làm chủ R cùng AI có phần làm sạch dữ liệu, vẽ biểu đồ và tổ chức phân tích bằng R. Phần ROC trong bài này có thể được dùng như một bài tập riêng, với điều kiện bạn kiểm tra lại mã và giả định trên bộ dữ liệu của mình.