การวิเคราะห์ความไวแบบอาศัยความน่าจะเป็น: จากการแจกแจงพารามิเตอร์สู่ CEAC

On this page
Read the English version
บทคัดย่อ
กองทุนสุขภาพแห่งหนึ่งต้องตัดสินใจว่าจะจ่ายค่าทางเลือก C หรือไม่ ค่าประมาณที่ดีที่สุดบอกว่า C มีต้นทุนมากกว่าการดูแลตามปกติ A คนละ 150,000 บาท และเพิ่มชีวิตที่สุขภาพสมบูรณ์ 1.00 ปี กองทุนยอมจ่ายได้ถึง 160,000 บาทต่อชีวิตที่สุขภาพสมบูรณ์เพิ่มหนึ่งปี ดังนั้น C จึงได้ผลได้สุทธิ 10,000 บาท แต่การทดลองที่อยู่เบื้องหลัง C มีขนาดเล็ก risk ratio ที่ได้ 0.56 จึงอาจมีค่าจริงที่ใดก็ได้ตั้งแต่ 0.25 ถึง 1.26 รวมถึงกรณีที่เป็นอันตราย บทความนี้นำความไม่แน่นอนนั้นเข้าไปในการตัดสินใจ ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวได้เส้นโค้งของค่าที่เป็นไปได้ซึ่งเหมาะกับชนิดของมัน ไม่ว่าจะเป็นอัตราส่วน อัตรา ต้นทุน หรือแถวของโอกาส จากนั้นรันแบบจำลอง 1,000 ครั้ง โดยแต่ละครั้งใช้ค่าใหม่ที่สุ่มจากทุกเส้นโค้ง คาดว่า C จะชนะ คือมีผลได้สุทธิเกิน 0 ในราว 599 ครั้ง คิดเป็นความน่าจะเป็น 0.60 บวกลบราว 3.0 จุดเปอร์เซ็นต์ เมื่อนับซ้ำที่เพดานทุกระดับที่กองทุนอาจยอมจ่ายต่อปีที่สุขภาพสมบูรณ์ จะได้เส้นโค้งที่กองทุนอ่านได้ทุกระดับ กล่อง R สั้น ๆ คำนวณตัวเลขทุกตัวซ้ำ
ทางเลือก C คุ้มกับเงินที่เพิ่ม 150,000 บาทไหม
คณะกรรมการของกองทุนสุขภาพแห่งหนึ่งต้องตัดสินใจว่าจะจ่ายค่าทางเลือก C ซึ่งเป็นการรักษาแบบใหม่ แทนการดูแลตามปกติ A หรือไม่ แบบจำลองของกองทุนให้ค่าประมาณที่ดีที่สุดว่า C มีต้นทุนมากกว่า A คนละ 150,000 บาท และเพิ่มชีวิตที่สุขภาพสมบูรณ์ 1.00 ปี กองทุนยอมจ่ายได้ถึง 160,000 บาทต่อชีวิตที่สุขภาพสมบูรณ์เพิ่มหนึ่งปี เราเรียกจำนวนสูงสุดที่กองทุนยอมจ่ายนี้ว่าเพดาน และจะลองเปลี่ยนค่านี้ภายหลัง เมื่อตีค่าอย่างนี้ C ให้สุขภาพมูลค่า 160,000 × 1.00 = 160,000 บาท โดยมีต้นทุนเพิ่ม 150,000 บาท จึงได้ผลได้สุทธิ 10,000 บาทต่อคน
ปีสุขภาวะ (quality-adjusted life year, QALY) คือปีชีวิตที่ถ่วงน้ำหนักด้วยคุณภาพ ตั้งแต่ 1 สำหรับสุขภาพสมบูรณ์ถึง 0 สำหรับการเสียชีวิต การอยู่ 10 ปีในสถานะสุขภาพที่ถ่วงน้ำหนัก 0.70 เท่ากับ 7.0 QALY ดังนั้นการกลับมาสุขภาพสมบูรณ์จึงได้เพิ่มอีก 3.0 QALY อ่านบทเรียนเต็มได้ที่ ปีสุขภาวะ ปีสุขภาวะที่สูญเสีย และค่าอรรถประโยชน์: เรากำลังวัดอะไรกันแน่ ในหน่วยนี้ C เพิ่ม 1.00 QALY ด้วยต้นทุนที่มากกว่า A 150,000 บาท และกองทุนยอมจ่ายได้ถึง 160,000 บาทต่อ QALY
ปัญหาคือข้อมูลนำเข้าทุกตัวของแบบจำลองเป็นค่าประมาณ การทดลองที่อยู่เบื้องหลัง C พบผู้ป่วยที่เกิดผลลัพธ์ที่ไม่ดี (เหตุการณ์) 8 รายในผู้ป่วยที่ได้รับการรักษา 66 คน และ 13 รายในกลุ่มควบคุม 60 คน คิดเป็นความเสี่ยง 12.12% และ 21.67% จึงได้ risk ratio 0.56 แต่ช่วงความเชื่อมั่น 95% กว้างตั้งแต่ 0.25 ถึง 1.26 และ ratio ที่มากกว่า 1 แปลว่า C ทำให้เกิดอันตราย
ข้อมูลนำเข้าอื่นก็ไม่แน่นอนเช่นกัน และตัวเลขในที่นี้เป็นข้อมูลสมมติเพื่อการสอน ประมาณได้ว่าสัดส่วนผู้ป่วยที่ตอบสนองต่อ C คือ 0.30 และการดูแลหนึ่งคอร์สมีต้นทุนเฉลี่ย 10,000 บาท ผู้ป่วยสุขภาพดี 100 คนถูกติดตามหนึ่งปี ผลคือ 80 คนยังสุขภาพดี, 15 คนป่วย และ 5 คนเสียชีวิต คณะกรรมการจึงถามว่ามั่นใจได้แค่ไหนว่า C คุ้มกับเงินที่เพิ่ม 150,000 บาท
บทความนี้ตอบเป็นสามขั้น ขั้นแรก ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งที่เหมาะกับชนิดของมัน ขั้นที่สอง รันแบบจำลองหลายครั้ง แต่ละครั้งใช้ค่าใหม่จากทุกเส้นโค้ง ขั้นสุดท้าย นับว่า C ชนะ คือมีผลได้สุทธิมากกว่า 0 บ่อยแค่ไหน ทั้งที่ 160,000 บาท และที่เพดานระดับอื่นทุกระดับ
กรณีตัวอย่างในรูปตัวเลข
# The fund's question in numbers: option C against usual care A, per patient.
# Writes: nothing to disk; the results print in the console.
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
Extra cost THB: 150,000 | extra health: 1.00 | price THB: 160,000 | net gain THB: 10,000
Trial risks: 12.12% 21.67% | risk ratio: 0.56
Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health: 7.0 3.0
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
ค่าประมาณเดียว ค่าที่เป็นไปได้หลายค่า
อัตราการตอบสนอง 0.30 ที่มี standard error 0.05 ไม่ใช่ค่าเดียว แต่เป็นช่วงของค่าที่อาจเป็นจริง ทั้งสูงกว่าและต่ำกว่า 0.30 เล็กน้อย โดยบางค่าเป็นไปได้มากกว่าค่าอื่น เมื่อวาดเป็นเส้นโค้ง เส้นจะสูงตรงค่าที่เป็นไปได้มากและต่ำตรงค่าที่เป็นไปได้น้อย นักสถิติเรียกเส้นโค้งแบบนี้ว่าการแจกแจง (distribution) การรันแบบจำลองแต่ละครั้งจะใช้ค่าหนึ่งค่าที่สุ่มจากเส้นโค้งของข้อมูลนำเข้าแต่ละตัว
เส้นโค้งนี้บอกว่าเรามั่นใจในค่าเฉลี่ยแค่ไหน ไม่ได้บอกว่าผู้ป่วยแต่ละคนต่างกันแค่ไหน standard error 0.05 วัดว่าอัตราที่ประมาณได้อาจห่างจากอัตราจริงได้แค่ไหน ผู้ป่วยแต่ละคนยังคงตอบสนองหรือไม่ตอบสนอง ส่วนเส้นโค้งเป็นเรื่องของอัตรา
รูปร่างของเส้นโค้งต้องเหมาะกับชนิดของข้อมูลนำเข้า อัตราส่วน (ratio เช่น risk ratio) ต่ำกว่า 0 ไม่ได้ ส่วนอัตรา (rate เช่น อัตราการตอบสนอง) ต้องอยู่ระหว่าง 0 ถึง 1 ต้นทุนติดลบไม่ได้ และแถวของโอกาสต้องรวมได้ 1 ทุกแถว สี่หัวข้อถัดไปสร้างเส้นโค้งแต่ละแบบจากตัวเลขของกรณีตัวอย่าง
risk ratio: เส้นโค้งรูประฆังสมมาตรบนสเกลลอการิทึม
เริ่มที่ risk ratio ของการทดลอง คือ 0.56 ที่มีช่วงความเชื่อมั่น 95% ตั้งแต่ 0.25 ถึง 1.26 ช่วงนี้ไม่สมมาตร 0.25 ห่างลงมาจาก 0.56 อยู่ 0.31 แต่ 1.26 ห่างขึ้นไปถึง 0.70 ถ้าใช้เส้นโค้งสมมาตรที่มีจุดกึ่งกลางอยู่ที่ 0.56 และมีความกว้างระดับนี้ จะได้บางค่าที่ต่ำกว่า 0 ซึ่งเป็นค่าที่อัตราส่วนเป็นไม่ได้
การใช้ลอการิทึมธรรมชาติแก้ปัญหาทั้งสองข้อ ลอการิทึมของ 0.25, 0.56 และ 1.26 คือ -1.386, -0.580 และ 0.231 บนสเกลนี้ช่วงจึงห่างจากจุดกึ่งกลางเท่ากันทั้งสองข้าง ค่าที่เป็นไปได้เรียงเป็นเส้นโค้งรูประฆังสมมาตร (เรียกสั้น ๆ ว่าระฆัง) ค่าที่อยู่ใกล้จุดกึ่งกลางพบบ่อย ค่าที่อยู่ห่างพบน้อย และในระฆังแบบนี้ 95% ของค่าอยู่ภายใน 1.96 เท่าของค่าการกระจายรอบจุดกึ่งกลาง
กฎข้อนี้ให้ค่าการกระจาย บนสเกลนี้ช่วงยาวจาก -1.386 ถึง 0.231 และครึ่งหนึ่งของความกว้างนั้นคือ 0.809 เมื่อนำ 0.809 หารด้วย 1.96 ได้ค่าการกระจาย 0.413 บนสเกลลอการิทึม ตรวจย้อนกลับได้ว่า -0.580 − 1.96 × 0.413 = -1.389 และ -0.580 + 1.96 × 0.413 = 0.229 ซึ่งปัดเป็น -1.39 และ 0.23
ระฆังสมมาตรนี้เรียกว่าการแจกแจงแบบปกติ (Normal distribution) ซึ่งราว 95% ของค่าอยู่ภายใน 1.96 เท่าของส่วนเบี่ยงเบนมาตรฐานรอบค่าเฉลี่ย ในที่นี้ค่าเฉลี่ยคือ -0.58 ส่วนเบี่ยงเบนมาตรฐานคือ 0.41 ซึ่งก็คือค่าการกระจายที่เพิ่งคำนวณ และช่วงกลางที่ครอบคลุม 95% อยู่ระหว่าง -1.39 ถึง 0.23
จากนั้นแปลงแต่ละค่ากลับเป็นอัตราส่วนด้วยการยก e กำลังค่านั้น ปลายทั้งสองกลายเป็น 0.249 และ 1.257 ซึ่งปัดเป็น 0.25 และ 1.26 ของการทดลอง ค่าที่แปลงแล้วทุกค่ามากกว่า 0 ค่าส่วนใหญ่กองอยู่ต่ำกว่า 1 และลาดยาวเลย 1.26 ออกไป โดย 8.0% ของค่าอยู่สูงกว่า 1 ซึ่งเป็นกรณีที่ C ทำให้เกิดอันตราย
เส้นโค้งนี้เรียกว่าการแจกแจงแบบล็อกนอร์มอล (lognormal distribution) คือเส้นโค้งของปริมาณที่ลอการิทึมของมันเป็นการแจกแจงแบบปกติ เป็นตัวเลือกที่นิยมใช้สำหรับอัตราส่วนที่ไม่แน่นอน เพราะไม่ต่ำกว่า 0 และให้ช่วงที่ไม่สมมาตรได้ตรงกับข้อมูล
จากช่วงความเชื่อมั่นของการทดลองสู่เส้นโค้ง
# The trial's risk ratio and its 95% interval, moved to the log scale and back.
# Writes: nothing to disk; the results print in the console.
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * pnorm(logs[2] / spread)), "\n")
Below | above 0.56: 0.31 0.70 | logs: -1.386 -0.580 0.231
Half width: 0.809 | / 1.96, the spread: 0.413 | rounded centre and spread: -0.58 0.41
Centre -/+ 1.96 spreads: -1.389 0.229 | rounded: -1.39 0.23
Back as ratios: 0.249 1.257 | rounded: 0.25 1.26
Draws above 1 (%): 8.0
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
อัตราการตอบสนอง: เส้นโค้งที่อยู่ระหว่าง 0 ถึง 1
อัตราการตอบสนองเป็นความน่าจะเป็น ค่าที่เป็นไปได้ทุกค่าจึงต้องอยู่ระหว่าง 0 ถึง 1 เส้นโค้งรูประฆังใช้ได้เมื่ออัตราอยู่ใกล้ 0.30 แต่อัตรา 0.05 ที่มี standard error เท่ากันจะล้นไปต่ำกว่า 0 เส้นโค้งจึงต้องมีกำแพงที่ 0 และที่ 1
ลองมองหลักฐานเป็นข้อมูลที่เทียบเท่าผู้ป่วยจำนวนหนึ่ง อัตราที่วัดจากผู้ป่วย n คนมี standard error ที่ยกกำลังสองแล้วเท่ากับ อัตรา × (1 − อัตรา) หารด้วย n สำหรับอัตราของเรา 0.30 × 0.70 = 0.21 (0.70 คือ 1 − 0.30) และ 0.05 × 0.05 = 0.0025 จึงได้ n = 0.21 / 0.0025 = 84 คน
เส้นโค้งที่เราจะใช้ต่อไปนี้ มีสูตรที่ n เท่ากับผลรวมของจำนวนสองค่าของมันบวกหนึ่ง ดังนั้นผลรวมของจำนวนสองค่าจึงเท่ากับ n ลบหนึ่ง: 84 − 1 = 83 คน ในจำนวนนี้ 0.30 × 83 = 24.9 คนตอบสนอง และ 0.70 × 83 = 58.1 คนไม่ตอบสนอง ซึ่ง 24.9 / 83 ให้อัตรา 0.30 ที่เราเริ่มต้นกลับมา
เส้นโค้งที่สร้างจากจำนวนสองค่าแบบนี้เรียกว่าการแจกแจงแบบเบตา (Beta distribution) ในที่นี้พารามิเตอร์รูปร่างทั้งสองค่า คือ 24.9 และ 58.1 ก็คือจำนวนเหล่านั้น เส้นโค้งแบบนี้เป็นเนินที่ไม่ออกนอกช่วง 0 ถึง 1 จึงเหมาะกับความเสี่ยง อัตราการตอบสนอง หรือค่าอรรถประโยชน์ และในที่นี้ช่วงกลางที่ครอบคลุม 95% อยู่ที่ 0.21 ถึง 0.40 ซึ่ง 0.21 นี้ตรงกับ 0.30 × 0.70 ข้างต้นโดยบังเอิญ
ต้นทุนต่อคอร์ส: เส้นโค้งที่อยู่เหนือศูนย์
ต้นทุนเฉลี่ยติดลบไม่ได้ และผู้ป่วยไม่กี่คนที่ค่าใช้จ่ายสูงดึงค่าเฉลี่ยขึ้นได้มากกว่าที่ผู้ป่วยค่าใช้จ่ายต่ำดึงลง เส้นโค้งจึงควรเริ่มที่ 0 และมีหางยาวไปทางขวา ในที่นี้ต้นทุนเฉลี่ยต่อคอร์สคือ 10,000 บาท และมี standard error 2,000 บาท
ลองมองต้นทุนต่อคอร์สเป็นชิ้นเล็ก ๆ 25 ชิ้น ชิ้นละราว 400 บาท รวมกันเป็น 25 × 400 = 10,000 บาท ซึ่งคือค่าเฉลี่ยของเรา เพราะแต่ละชิ้นแกว่งได้ ยอดรวมจึงแกว่งเท่ากับรากที่สองของ 25 ซึ่งคือ 5 คูณ 400 บาท ได้ 2,000 บาท ซึ่งคือ standard error ของเรา ตัวเลขสองตัว 25 และ 400 จึงให้ค่าประมาณทั้งสองกลับมาครบ
การหาสองตัวเลขนี้จากค่าเฉลี่ยและ standard error ให้คิดย้อนกลับ ตัวแรกคือรูปร่าง เท่ากับค่าเฉลี่ยหารด้วย standard error แล้วยกกำลังสอง: 10,000 / 2,000 = 5 และ 5 × 5 ได้รูปร่าง 25 ตัวที่สองคือสเกล เท่ากับ standard error ยกกำลังสองหารด้วยค่าเฉลี่ย: 2,000 × 2,000 = 4,000,000 และ 4,000,000 / 10,000 = 400 บาท
เส้นโค้งที่มีหางยาวไปทางขวานี้เรียกว่าการแจกแจงแบบแกมมา (Gamma distribution) ในที่นี้มีรูปร่าง 25 และสเกล 400 บาท ช่วงกลางที่ครอบคลุม 95% อยู่ที่ 6,471 ถึง 14,284 บาท คือต่ำกว่าค่าเฉลี่ย 3,529 บาท แต่สูงกว่าค่าเฉลี่ย 4,284 บาท ซึ่งเป็นฝั่งขวาที่ยาวกว่า ยอดของเส้นโค้งอยู่ที่ 9,600 บาท ต่ำกว่าค่าเฉลี่ยเล็กน้อย
จากอัตราและ standard error สู่ค่ารูปร่างสองค่า
# The response rate: an estimate of 0.30 with a standard error of 0.05.
# Writes: nothing to disk; the results print in the console.
rate_mean <- 0.30
rate_se <- 0.05
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
0.30 x 0.70: 0.21 | 0.05 x 0.05: 0.0025
Divided: 84 | minus 1: 83
Shapes, responders | non-responders: 24.9 58.1 | their mean: 0.3
Middle 95%: 0.21 0.4
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
จากต้นทุนและ standard error สู่รูปร่างและสเกล
# The mean cost of a course of care: 10,000 THB with a standard error of 2,000 THB.
# Writes: nothing to disk; the results print in the console.
cost_mean <- 10000
cost_se <- 2000
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
cost_ends <- qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
Mean / standard error: 5 | squared, the shape: 25
2,000 x 2,000: 4,000,000 | / 10,000, the scale THB: 400 | shape x scale: 10,000
Middle 95% THB: 6,471 14,284 | peak THB: 9,600
Below | above mean THB: 3,529 4,284
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
แถวของโอกาสที่ต้องรวมได้ 1
แบบจำลองของกองทุนยังติดตามผู้ป่วยไปทีละปีด้วย แบบจำลองมาร์คอฟ (Markov model) คือการติดตามกลุ่มคนที่ย้ายระหว่างสถานะสุขภาพไม่กี่สถานะทีละรอบ ใน 1,000 คนที่เริ่มสุขภาพดี ในรอบแรก 800 คนยังสุขภาพดี, 150 คนป่วย และ 50 คนเสียชีวิต อ่านบทเรียนเต็มได้ที่ สร้างแบบจำลองมาร์คอฟแบบกลุ่มประชากรทีละขั้น: ติดตามประชากรผ่านเวลา การย้ายเหล่านี้มาจากโอกาสแถวเดียว คือ 0.80, 0.15 และ 0.05 ซึ่งนับได้เป็น 80, 15 และ 5 จากผู้ป่วยสุขภาพดี 100 คน
โอกาสทั้งสามแบ่งทุกคนในสถานะสุขภาพดีออกไป จึงต้องรวมได้ 1 สมมติว่าสุ่มโอกาสแต่ละตัวจากเส้นโค้งแบบเบตาของตัวเอง โดยเส้นโค้งของโอกาสแรกสร้างจากจำนวนที่นับได้ 80 กับคนที่เหลือจาก 100 คน และทำนองเดียวกันกับ 15 และ 5 แต่ไม่มีอะไรเชื่อมการสุ่มทั้งสามไว้ ผลรวมจึงแกว่งไปมา โดยในสามแถวที่สุ่มแบบนั้น ผลรวมออกมาเป็น 0.911, 0.940 และ 1.115 สองแถวแรกทำให้ผู้ป่วยหายไป และแถวสุดท้ายสร้างผู้ป่วยขึ้นมาเอง
ทางแก้คือสุ่มตัวเลขที่เป็นบวกมาหนึ่งตัวสำหรับจำนวนที่นับได้แต่ละตัว คือ 80, 15 และ 5 โดยสุ่มจากเส้นโค้งแบบแกมมาที่มีรูปร่างเท่ากับจำนวนนั้น แล้วหารแต่ละตัวด้วยผลรวมของทั้งสามตัว สามแถวที่สุ่มแบบนี้ได้ผลดังนี้ แถวแรก 0.819, 0.149 และ 0.033 แถวที่สอง 0.842, 0.078 และ 0.080 แถวที่สาม 0.805, 0.166 และ 0.029 เรียงตามสุขภาพดี ป่วย และเสียชีวิต สัดส่วนทั้งสามแบ่งจากยอดรวมเดียวกัน แต่ละแถวจึงรวมได้ 1 พอดีก่อนปัดทศนิยม และเมื่อสุ่มหลายแถว ค่าจะเฉลี่ยออกมาใกล้ 0.80, 0.15 และ 0.05 ซึ่งเป็นสัดส่วนที่นับได้
การสุ่มทั้งแถวแบบนี้เรียกว่าการแจกแจงแบบดีริชเลต์ (Dirichlet distribution) ทำหน้าที่แบบเดียวกับการแจกแจงแบบเบตา แต่ใช้กับผลลัพธ์ตั้งแต่สามทางขึ้นไป เมื่อใช้จำนวน 80, 15 และ 5 แถวที่สุ่มได้มีค่าเฉลี่ย 0.80, 0.15 และ 0.05 และทุกแถวรวมได้ 1
แถวที่สุ่มพร้อมกันและสุ่มทีละค่า
# Yearly chances for a healthy patient: of 100, 80 stay healthy, 15 fall sick and 5 die.
# Writes: nothing to disk; the results print in the console.
counts <- c(80, 15, 5)
cohort <- 1000
set.seed(20261028)
together <- matrix(rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
Average row: 0.80 0.15 0.05 | 1,000 healthy, one year on: 800 150 50
Rows drawn together: 0.819 0.149 0.033 0.842 0.078 0.080 0.805 0.166 0.029 | sums: 1 1 1
Rows drawn one share at a time: 0.763 0.112 0.036 0.730 0.138 0.072 0.819 0.208 0.088
Their sums: 0.911 0.940 1.115
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
รันแบบจำลองหลายครั้ง
คำถามของคณะกรรมการต้องให้ข้อมูลนำเข้าทุกตัวไม่แน่นอนพร้อมกัน เพราะ risk ratio ที่ออกมาไม่ดี (ใกล้หรือเกิน 1) กับคอร์สที่มีต้นทุนสูงอาจเกิดในการรันครั้งเดียวกัน จึงต้องรันแบบจำลองซ้ำหลายครั้ง ในแต่ละครั้ง แบบจำลองสุ่มค่าหนึ่งค่าจากทุกเส้นโค้ง คำนวณ C และ A จากชุดค่าที่สุ่มชุดเดียวกัน แล้วบันทึก QALY ที่เพิ่มและต้นทุนที่เพิ่มของ C
ในแบบจำลองเต็มของกองทุน เส้นโค้งทั้งสี่เส้นจะป้อนค่าให้ทุกการรัน ซึ่งทำให้สิ่งต่อไปนี้เปลี่ยนไป คือสุขภาพของ C, ต้นทุนของ C และการย้ายสถานะรายปีของผู้ป่วย การสร้างแบบจำลองทั้งตัวเป็นบทเรียนอีกบทหนึ่ง ในที่นี้จึงใช้เพียงตัวเลขสรุปที่การรันของแบบจำลองนั้นจะให้ มาแทนตัวแบบจำลองเอง เมื่อดูตลอดการรันทั้งหมด QALY ที่เพิ่มของ C มีค่าเฉลี่ย 1.00 และส่วนเบี่ยงเบนมาตรฐาน 0.20 ส่วนต้นทุนที่เพิ่มมีค่าเฉลี่ย 150,000 บาท และส่วนเบี่ยงเบนมาตรฐาน 24,000 บาท ค่าการกระจายทั้งสองนี้เป็นข้อมูลสมมติ เส้นโค้งทั้งสี่สอนรูปร่าง ส่วนการคำนวณต่อจากนี้ใช้เพียงสรุปผลสองค่านี้
ในแต่ละครั้ง ผลได้สุทธิของ C ที่เพดาน 160,000 บาทต่อ QALY คือ 160,000 × QALY ที่เพิ่ม ลบด้วยต้นทุนที่เพิ่ม ตัวอย่างคำนวณด้านล่างแสดงที่มาของผลได้สุทธิเฉลี่ยและค่าการกระจาย และแสดงว่าผลได้สุทธิมากกว่า 0 บ่อยแค่ไหน
ตัวอย่างคำนวณ: C ชนะบ่อยแค่ไหน
แต่ละครั้งให้ QALY ที่เพิ่มของ C ซึ่งสุ่มโดยมีค่ากลางที่ 1.00 (ค่าการกระจาย 0.20) และต้นทุนที่เพิ่มซึ่งสุ่มโดยมีค่ากลางที่ 150,000 บาท (ค่าการกระจาย 24,000 บาท) ผลได้สุทธิคือ 160,000 × QALY ที่เพิ่ม ลบด้วยต้นทุนที่เพิ่ม
-
ผลได้สุทธิเฉลี่ย
\[ 160000 \times 1.00 - 150000 = 10000 \]
ใส่ค่าเฉลี่ยลงไป โดยเฉลี่ย C ได้ผลได้สุทธิ 10,000 บาท
-
ค่าการกระจายจากแต่ละด้าน
\[ 160000 \times 0.20 = 32000 \]
ด้านสุขภาพแกว่ง 32,000 บาท และด้านต้นทุนแกว่ง 24,000 บาท
-
ค่าการกระจายรวมกันแบบกำลังสอง
\[ 32 \times 32 + 24 \times 24 = 1024 + 576 = 1600 = 40 \times 40 \]
เมื่อสองด้านแกว่งอย่างอิสระต่อกัน บางครั้งค่าสุขภาพที่สุ่มได้สูงก็มาคู่กับต้นทุนที่สุ่มได้สูง การแกว่งจึงหักล้างกันบางส่วน และยอดรวมแกว่งน้อยกว่าผลบวกของค่าการกระจายทั้งสอง ค่ายกกำลังสองต่างหากที่บวกกันได้ สูตรนี้คิดเป็นหน่วยพันบาท (32 คือ 32,000 บาท และ 24 คือ 24,000 บาท) จึงได้ 40 พันบาท ค่าการกระจายของผลได้สุทธิจึงเท่ากับ 40,000 บาท
-
ค่าเฉลี่ยอยู่เหนือศูนย์แค่ไหน
\[ 10000 / 40000 = 0.25 \]
ค่าเฉลี่ยอยู่เหนือศูนย์หนึ่งในสี่ของค่าการกระจาย
-
สัดส่วนที่อยู่เหนือศูนย์
\[ 0.599 \times 1000 = 599 \]
ผลได้สุทธิซึ่งเป็นผลต่างของเส้นโค้งรูประฆังสองเส้นก็เป็นการแจกแจงแบบปกติเช่นกัน ศูนย์อยู่ต่ำกว่าค่าเฉลี่ยลงไป 0.25 เท่าของค่าการกระจาย ค่าที่อยู่เหนือศูนย์จึงมีสัดส่วน 0.599 ซึ่งอ่านได้จากตารางการแจกแจงแบบปกติ หรือ pnorm(0.25) ใน R
ผลลัพธ์: C ชนะในราว 599 จาก 1,000 ครั้ง คิดเป็นความน่าจะเป็น 0.60 ที่ C คุ้มกับต้นทุนที่เพิ่มที่เพดาน 160,000 บาทต่อ QALY
ชื่อของวิธีนี้
การรันแบบจำลองซ้ำหลายครั้ง โดยแต่ละครั้งสุ่มข้อมูลนำเข้าที่ไม่แน่นอนทุกตัวจากเส้นโค้งของมัน เรียกว่าการวิเคราะห์ความไวเชิงความน่าจะเป็น (probabilistic sensitivity analysis, PSA) คำตอบของมันคือสัดส่วนของการรัน ไม่ใช่ผลลัพธ์ที่ดีที่สุดเพียงค่าเดียว ในที่นี้ ตามเส้นโค้งรูประฆัง คาดว่า C จะชนะราว 599 จาก 1,000 ครั้ง กลุ่มจุดด้านล่างแสดงคำตอบเดียวกันในรูปจุด
ค่าเฉลี่ย ค่าการกระจาย และสัดส่วนเหนือศูนย์ของผลได้สุทธิ
# Net gain of C at 160,000 THB per year of full health, with both of its results uncertain.
# Writes: nothing to disk; the results print in the console.
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- pnorm(gain_mean / gain_sd)
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
Average net gain THB: 10000 | spread parts THB: 32000 24000
In thousands: 32 24 | squared: 1024 576 | sum: 1600 | root: 40 | spread THB: 40000
Average / spread: 0.25 | share above zero: 0.599 | rounded: 0.6 | runs of 1,000: 599
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
ต้องรันกี่ครั้งจึงพอ
การคำนวณจากเส้นโค้งรูประฆังให้ 599 ครั้งจาก 1,000 ครั้ง แต่ชุดการรันจริงเป็นการสุ่ม เราลองรันจริง 1,000 ครั้ง โดยกำหนดจุดตั้งต้นของการสุ่มไว้เพื่อให้ใครก็รันซ้ำได้ ผลคือ C ชนะ 592 ครั้ง ผลได้สุทธิของการรันเหล่านี้มีค่าเฉลี่ย 9,236 บาท และค่าการกระจาย 39,057 บาท ใกล้กับ 10,000 บาท และ 40,000 บาท ในตัวอย่างคำนวณ
ช่วงแรกผลนับแกว่งมาก สัดส่วนคือ 0.400 หลังรัน 10 ครั้ง, 0.510 หลังรัน 100 ครั้ง และ 0.592 หลังรัน 1,000 ครั้ง การรันมากขึ้นทำให้ค่านิ่งขึ้น แต่ยังมีการแกว่งเหลืออยู่เสมอ
ขนาดของการแกว่งคำนวณได้จากสัดส่วนและจำนวนครั้งที่รัน สำหรับสัดส่วน 0.60 ผลคูณ 0.60 × 0.40 = 0.24 วัดว่าผลการรันปนกันแค่ไหน นำค่านี้หารด้วยการรัน 1,000 ครั้งแล้วถอดรากที่สอง จะได้ standard error ของสัดส่วนเท่ากับ 0.0155 คูณด้วย 1.96 ได้ราว 3.0 จุดเปอร์เซ็นต์ การรันอีกชุด 1,000 ครั้งจึงน่าจะได้ค่าระหว่าง 0.57 ถึง 0.63 เช่นกัน
การตอบคำถามด้วยการสุ่มข้อมูลนำเข้าซ้ำหลายครั้งแล้วนับผล เรียกว่าการจำลองแบบมอนติคาร์โล (Monte Carlo simulation) ส่วนการแกว่งที่ยังเหลืออยู่เรียกว่า Monte Carlo error ในที่นี้ standard error ของสัดส่วนคือ 0.0155 ซึ่งทำให้ค่าแกว่งราว 3.0 จุดเปอร์เซ็นต์ทั้งสองทางที่ 60% และการรันมากขึ้นทำให้ค่านี้เล็กลง วิธีรันแบบจำลองการตัดสินใจทั้งตัวด้วยวิธีนี้ใน R มีอธิบายไว้ (Jalal and colleagues)
การรัน 1,000 ครั้งและการแกว่งที่เหลืออยู่
# Run the stand-in model 1,000 times and count how often C comes out ahead.
# Writes: nothing to disk; the results print in the console.
set.seed(20261028)
draws <- 1000
gain_draws <- 160000 * rnorm(draws, 1.00, 0.20) - rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
Runs ahead of 1,000: 592 | average, spread THB: 9236 39057
Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs: 0.400 0.440 0.480 0.510 0.592 0.584 0.592
Share (%): 60 | 0.60 x 0.40: 0.24
One standard error: 0.0155 | x 1.96, in points: 3.0 | range: 0.57 0.63
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
นับซ้ำที่เพดานทุกระดับ
เพดาน 160,000 บาทต่อ QALY ของกองทุนเป็นการตัดสินใจเชิงนโยบาย และคณะกรรมการมักถกเถียงกันเรื่องนี้ จึงควรนับซ้ำที่เพดานระดับอื่น ที่แต่ละระดับ ผลได้สุทธิเฉลี่ยและค่าการกระจายเปลี่ยนไป สัดส่วนของการรันที่ C ชนะก็เปลี่ยนตาม
ที่เพดาน 100,000 บาทต่อ QALY ผลได้สุทธิเฉลี่ยของ C คือ 100,000 × 1.00 − 150,000 = -50,000 บาท ค่าการกระจายคือ 31,241 บาท ซึ่งคำนวณแบบเดียวกับตัวอย่างข้างต้น ค่าเฉลี่ยจึงอยู่ที่ -1.60 เท่าของค่าการกระจาย คือต่ำกว่าศูนย์ ดังนั้น C ชนะเพียง 0.055 ของการรัน ที่ 150,000 บาท ค่าเฉลี่ยเท่ากับ 0 และสัดส่วนคือ 0.500 ส่วนที่ 200,000 บาท และ 300,000 บาท สัดส่วนเพิ่มเป็น 0.858 และ 0.990 ตามลำดับ
การวาดสัดส่วนนี้เทียบกับเพดานทุกระดับได้เส้นโค้งเส้นหนึ่ง เส้นนี้เรียกว่าเส้นโค้งการยอมรับความคุ้มค่า (cost-effectiveness acceptability curve, CEAC) ซึ่งบอกว่า ที่เพดานแต่ละระดับที่กองทุนอาจจ่ายต่อ QALY ทางเลือก C เป็นทางเลือกที่คุ้มกว่าในสัดส่วนเท่าใดของการรัน เมื่ออ่านที่ 160,000 บาท ได้ 0.599 หรือประมาณ 0.60 และเส้นของ A ซึ่งเป็นภาพสะท้อนได้ 0.401 (รวมกันเป็น 1)
เส้นทั้งสองตัดกันที่ 0.50 ที่ 150,000 บาท ซึ่งคือต้นทุนต่อ QALY ที่เพิ่มของ C เอง (150,000 บาท / 1.00 = 150,000 บาทต่อ QALY) ดังนั้นที่เพดานนี้ ผลได้สุทธิเฉลี่ยของ C จึงเท่ากับ 0 บาท ต่ำกว่าระดับนั้น A ชนะในการรันส่วนใหญ่ และสูงกว่านั้น C ชนะ แม้ที่ 300,000 บาท C ก็ชนะ 0.990 ของการรัน ไม่ใช่ทุกครั้ง เพราะในการรันบางครั้งสุขภาพที่เพิ่มของ C น้อย หรือต้นทุนที่เพิ่มสูง
สัดส่วนของการรันที่เพดานห้าระดับ
# Repeat the count at five prices per year of full health.
# Writes: nothing to disk; the results print in the console.
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - pnorm(centre / width)), "\n")
Prices THB: 100,000 150,000 160,000 200,000 300,000
Average net gain THB: -50,000 0 10,000 50,000 150,000
Spread THB: 31,241 38,419 40,000 46,648 64,622
Average / spread: -1.60 0.00 0.25 1.07 2.32
Share of runs where C is ahead: 0.055 0.500 0.599 0.858 0.990
Share where A is ahead: 0.945 0.500 0.401 0.142 0.010
ต้องใช้ R ≥ 4.5.2 · ggplot2 4.0.3 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด probabilistic-sensitivity-analysis-distributions-and-ceac.R
สคริปต์ฉบับเต็ม
# Uncertain inputs as curves, many runs of a stand-in model, and the share of wins at each price
# Give each uncertain input of the fund's decision a curve, rerun a stand-in model 1,000 times.
# ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งของตัวเอง แล้วรันแบบจำลองแทน 1,000 ครั้ง
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# ggplot2 4.0.3 (stable)
# install.packages(c("ggplot2"))
# update.packages(oldPkgs = c("ggplot2"))
# remotes::install_version("ggplot2", "4.0.3")
library(ggplot2) # Display the shares across prices without saving files.
stopifnot(getRversion() >= "4.5.2")
#%%@note 1. Define the illustrative inputs
# Every input is synthetic and written out once, in the order the article uses it.
# ค่าตั้งต้นทั้งหมดเป็นข้อมูลสมมติ เขียนไว้ครั้งเดียวตามลำดับที่บทความใช้
extra_cost <- 150000
extra_health <- 1.00
price <- 160000
events <- c(8, 13)
patients <- c(66, 60)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
ratio <- c(0.25, 0.56, 1.26)
z95 <- 1.96
rate_mean <- 0.30
rate_se <- 0.05
cost_mean <- 10000
cost_se <- 2000
counts <- c(80, 15, 5)
cohort <- 1000
health_avg_sd <- c(1.00, 0.20)
cost_avg_sd <- c(150000, 24000)
wtp <- 160000
draws <- 1000
prices <- c(100000, 150000, 160000, 200000, 300000)
health_c <- c(1.00, 0.20)
cost_c <- c(150000, 24000)
#%%@note 2. Move the trial's ratio to the log scale
# On the log scale the interval is symmetric, so its half width over 1.96 gives the spread.
# บนสเกลลอการิทึม ช่วงความเชื่อมั่นสมมาตร ครึ่งความกว้างหารด้วย 1.96 จึงได้ค่าการกระจาย
logs <- log(ratio)
half_width <- (logs[3] - logs[1]) / 2
spread <- half_width / z95
ends <- logs[2] + c(-1, 1) * z95 * spread
#%%@note 3. Turn a mean and a standard error into curve shapes
# A rate needs a curve between 0 and 1, a cost needs one above zero.
# อัตราต้องใช้เส้นโค้งระหว่าง 0 ถึง 1 ส่วนต้นทุนต้องใช้เส้นโค้งที่อยู่เหนือศูนย์
se_squared <- rate_se^2
patients_worth <- rate_mean * (1 - rate_mean) / se_squared - 1
shapes <- c(rate_mean, 1 - rate_mean) * patients_worth
cost_shape <- (cost_mean / cost_se)^2
cost_scale <- cost_se^2 / cost_mean
cost_ends <- stats::qgamma(c(0.025, 0.975), cost_shape, scale = cost_scale)
#%%@note 4. Draw whole rows of yearly chances
# Rows drawn together always add up to 1, rows drawn one share at a time do not.
# แถวที่สุ่มพร้อมกันรวมได้ 1 เสมอ ส่วนแถวที่สุ่มทีละค่าไม่รวมได้ 1
set.seed(20261028)
together <- matrix(stats::rgamma(9, shape = rep(counts, 3)), nrow = 3)
together <- together / rep(colSums(together), each = 3)
apart <- matrix(stats::rbeta(9, rep(counts, 3), rep(sum(counts) - counts, 3)), nrow = 3)
#%%@note 5. Combine the two uncertain results into one net gain
# Net gain is price x extra health - extra cost, and independent spreads add as squares.
# ผลได้สุทธิคือราคา x สุขภาพที่เพิ่ม - ต้นทุนที่เพิ่ม ค่าการกระจายที่อิสระต่อกันรวมกันแบบกำลังสอง
gain_mean <- wtp * health_avg_sd[1] - cost_avg_sd[1]
parts <- c(wtp * health_avg_sd[2], cost_avg_sd[2]) / 1000
gain_sd <- sqrt(sum(parts^2)) * 1000
above <- stats::pnorm(gain_mean / gain_sd)
#%%@note 6. Run the stand-in model 1,000 times
# Each run draws extra health and extra cost afresh, then records whether C comes out ahead.
# แต่ละครั้งสุ่มสุขภาพที่เพิ่มและต้นทุนที่เพิ่มใหม่ แล้วบันทึกว่า C ชนะหรือไม่
set.seed(20261028)
gain_draws <- 160000 * stats::rnorm(draws, 1.00, 0.20) - stats::rnorm(draws, 150000, 24000)
running <- cumsum(gain_draws > 0) / seq_len(draws)
mc_se <- sqrt(0.60 * 0.40 / draws)
#%%@note 7. Repeat the count at five prices
# The share of runs where C is ahead, price by price.
# สัดส่วนของการรันที่ C ชนะ ทีละระดับราคา
centre <- prices * health_c[1] - cost_c[1]
width <- sqrt((prices * health_c[2])^2 + cost_c[2]^2)
curve_table <- data.frame(price = prices, share_c = stats::pnorm(centre / width))
print(ggplot2::ggplot(curve_table, ggplot2::aes(x = price, y = share_c)) +
ggplot2::geom_line() + ggplot2::geom_point())
#%%@note 8. Numbers the article quotes
# Print exactly the short boxes in their reading order.
# พิมพ์ผลลัพธ์ตรงกับกล่องตัวอย่างตามลำดับในบทความ
cat("Extra cost THB:", big(extra_cost), "| extra health:", sprintf("%.2f", extra_health),
"| price THB:", big(price), "| net gain THB:", big(price * extra_health - extra_cost), "\n")
cat("Trial risks:", paste0(sprintf("%.2f", 100 * events / patients), "%"), "| risk ratio:",
round((events[1] / patients[1]) / (events[2] / patients[2]), 2), "\n")
cat("Weight 1 = full health, 0 = death | 10 years at 0.70 | gain from full health:",
sprintf("%.1f", 10 * c(0.70, 1 - 0.70)), "\n")
cat("Below | above 0.56:", sprintf("%.2f", diff(ratio)), "| logs:", sprintf("%.3f", logs), "\n")
cat("Half width:", sprintf("%.3f", half_width), "| / 1.96, the spread:", sprintf("%.3f", spread),
"| rounded centre and spread:", sprintf("%.2f", c(logs[2], spread)), "\n")
cat("Centre -/+ 1.96 spreads:", sprintf("%.3f", ends), "| rounded:", sprintf("%.2f", ends), "\n")
cat("Back as ratios:", sprintf("%.3f", exp(ends)), "| rounded:", sprintf("%.2f", exp(ends)), "\n")
cat("Draws above 1 (%):", sprintf("%.1f", 100 * stats::pnorm(logs[2] / spread)), "\n")
cat("0.30 x 0.70:", rate_mean * (1 - rate_mean), "| 0.05 x 0.05:",
formatC(se_squared, format = "f", digits = 4), "\n")
cat("Divided:", rate_mean * (1 - rate_mean) / se_squared, "| minus 1:", patients_worth, "\n")
cat("Shapes, responders | non-responders:", shapes, "| their mean:", shapes[1] / sum(shapes), "\n")
cat("Middle 95%:", round(stats::qbeta(c(0.025, 0.975), shapes[1], shapes[2]), 2), "\n")
cat("Mean / standard error:", cost_mean / cost_se, "| squared, the shape:", cost_shape, "\n")
cat("2,000 x 2,000:", big(cost_se^2), "| / 10,000, the scale THB:", cost_scale,
"| shape x scale:", big(cost_shape * cost_scale), "\n")
cat("Middle 95% THB:", big(cost_ends), "| peak THB:", big((cost_shape - 1) * cost_scale), "\n")
cat("Below | above mean THB:", big(cost_mean - cost_ends[1]), big(cost_ends[2] - cost_mean), "\n")
cat("Average row:", sprintf("%.2f", counts / sum(counts)), "| 1,000 healthy, one year on:",
cohort * counts / sum(counts), "\n")
cat("Rows drawn together:", sprintf("%.3f", together), "| sums:", colSums(together), "\n")
cat("Rows drawn one share at a time:", sprintf("%.3f", apart), "\n")
cat("Their sums:", sprintf("%.3f", colSums(apart)), "\n")
cat("Average net gain THB:", gain_mean, "| spread parts THB:", 1000 * parts, "\n")
cat("In thousands:", parts, "| squared:", parts^2, "| sum:", sum(parts^2), "| root:",
sqrt(sum(parts^2)), "| spread THB:", gain_sd, "\n")
cat("Average / spread:", gain_mean / gain_sd, "| share above zero:", round(above, 3), "| rounded:",
round(above, 2), "| runs of 1,000:", round(1000 * above), "\n")
cat("Runs ahead of 1,000:", sum(gain_draws > 0), "| average, spread THB:", round(mean(gain_draws)),
round(stats::sd(gain_draws)), "\n")
cat("Share ahead after 10, 25, 50, 100, 250, 500, 1000 runs:",
sprintf("%.3f", running[c(10, 25, 50, 100, 250, 500, 1000)]), "\n")
cat("Share (%):", 60, "| 0.60 x 0.40:", 0.60 * 0.40, "\n")
cat("One standard error:", formatC(mc_se, format = "f", digits = 4), "| x 1.96, in points:",
sprintf("%.1f", 196 * mc_se), "| range:", sprintf("%.2f", 0.60 + c(-1, 1) * 1.96 * mc_se), "\n")
cat("Prices THB:", formatC(prices, format = "d", big.mark = ","), "\n")
cat("Average net gain THB:", formatC(centre, format = "d", big.mark = ","), "\n")
cat("Spread THB:", formatC(round(width), format = "d", big.mark = ","), "\n")
cat("Average / spread:", sprintf("%.2f", centre / width), "\n")
cat("Share of runs where C is ahead:", sprintf("%.3f", stats::pnorm(centre / width)), "\n")
cat("Share where A is ahead:", sprintf("%.3f", 1 - stats::pnorm(centre / width)), "\n")
เลือกตามค่าเฉลี่ย แล้วรายงานสัดส่วน
เส้นโค้งบอกกองทุนว่ามั่นใจได้แค่ไหน แต่ไม่ได้เลือกทางเลือกให้ ให้เลือกทางเลือกที่มีผลได้สุทธิเฉลี่ยสูงกว่าที่เพดานของกองทุน คือ A เมื่อต่ำกว่า 150,000 บาท และ C เมื่อสูงกว่านั้น การวาดเฉพาะสัดส่วนของทางเลือกนั้นที่เพดานแต่ละระดับได้ขอบเขตการยอมรับความคุ้มค่า (cost-effectiveness acceptability frontier, CEAF) ซึ่งคือแถบจางในภาพ
เมื่อมีทางเลือกตั้งแต่สามทางขึ้นไป ทางเลือกที่ชนะบ่อยที่สุดอาจไม่ใช่ทางเลือกที่มีผลได้สุทธิเฉลี่ยสูงสุด เพราะอาจชนะทีละน้อยแต่แพ้ทีละมาก ให้เลือกตามค่าเฉลี่ย แล้วรายงานสัดส่วนไว้ข้างกัน (Briggs, Claxton and Sculpher)
กองทุนพูดอะไรได้บ้าง
ตอนนี้คณะกรรมการตอบได้ครบขึ้น ที่เพดาน 160,000 บาทต่อ QALY โดยเฉลี่ย C เป็นทางเลือกที่ดีกว่า ด้วยผลได้สุทธิ 10,000 บาทต่อคน และชนะในราว 0.60 ของการรัน บวกลบราว 3.0 จุดเปอร์เซ็นต์ จึงยังเหลือราว 0.40 ของการรันที่มูลค่าของสุขภาพที่ C เพิ่มขึ้นน้อยกว่าเงิน 150,000 บาทที่จ่ายเพิ่มสำหรับ C
คำตอบนี้เชื่อถือได้เท่ากับที่เส้นโค้งเชื่อถือได้เท่านั้น เส้นโค้งแต่ละเส้นต้องมาจากหลักฐานที่อยู่เบื้องหลังข้อมูลนำเข้าตัวนั้น (Briggs and colleagues) ข้อมูลนำเข้าที่ขึ้นลงไปด้วยกัน เช่น ต้นทุนสองรายการจากโรงพยาบาลกลุ่มเดียวกัน ต้องสุ่มไปพร้อมกันเหมือนแถวของโอกาส ไม่ใช่สุ่มทีละค่า ในที่นี้เราถือว่าสุขภาพที่เพิ่มและต้นทุนที่เพิ่มเป็นอิสระต่อกัน
สัดส่วน 0.60 ยังนำไปสู่คำถามใหม่ว่า ควรจ่ายเงินทำวิจัยเพิ่มก่อนตัดสินใจหรือไม่ บทความถัดไปในชุดนี้ตีมูลค่าของการขจัดความไม่แน่นอนนี้ (Fenwick and colleagues)
คำศัพท์ตามที่บทความนี้ใช้
- Distribution (การแจกแจง)
- เส้นโค้งของค่าที่ข้อมูลนำเข้าที่ไม่แน่นอนอาจเป็น สูงตรงค่าที่เป็นไปได้มาก
- Normal distribution (การแจกแจงแบบปกติ)
- เส้นโค้งรูประฆังสมมาตร ราว 95% ของค่าอยู่ภายใน 1.96 เท่าของส่วนเบี่ยงเบนมาตรฐานรอบค่าเฉลี่ย
- Lognormal distribution (การแจกแจงแบบล็อกนอร์มอล)
- เส้นโค้งของอัตราส่วนที่ลอการิทึมเป็นระฆังแบบปกติ ไม่ต่ำกว่า 0 และฝั่งขวายาวกว่า
- Beta distribution (การแจกแจงแบบเบตา)
- เนินระหว่าง 0 ถึง 1 สำหรับความน่าจะเป็นที่ไม่แน่นอน กำหนดด้วยจำนวนสองค่า เช่น 24.9 และ 58.1
- Gamma distribution (การแจกแจงแบบแกมมา)
- เส้นโค้งที่อยู่เหนือ 0 และมีหางยาวไปทางขวา สำหรับต้นทุนที่ไม่แน่นอน กำหนดด้วยรูปร่างและสเกล เช่น 25 และ 400 บาท
- Dirichlet distribution (การแจกแจงแบบดีริชเลต์)
- วิธีสุ่มโอกาสทั้งแถวพร้อมกัน ทุกแถวที่สุ่มได้จึงรวมได้ 1
- Probabilistic sensitivity analysis (PSA) (การวิเคราะห์ความไวเชิงความน่าจะเป็น)
- การรันแบบจำลองซ้ำหลายครั้งโดยสุ่มข้อมูลนำเข้าที่ไม่แน่นอนทุกตัวจากเส้นโค้งของมัน แล้วรายงานสัดส่วนของการรัน
- Monte Carlo simulation (การจำลองแบบมอนติคาร์โล)
- การตอบคำถามด้วยการสุ่มข้อมูลนำเข้าซ้ำหลายครั้งแล้วนับผล การแกว่งที่เหลือคือ Monte Carlo error
- Cost-effectiveness acceptability curve (CEAC) (เส้นโค้งการยอมรับความคุ้มค่า)
- สัดส่วนของการรันที่ทางเลือกหนึ่งคุ้มกว่า วาดเทียบกับเพดานที่จ่ายต่อ QALY
- Acceptability frontier (CEAF) (ขอบเขตการยอมรับความคุ้มค่า)
- สัดส่วนของการรันของทางเลือกที่มีผลได้สุทธิเฉลี่ยสูงกว่า ทีละระดับเพดาน
เชื่อมกับบทความพื้นฐาน
บทความพื้นฐานเรื่อง “ต้นไม้การตัดสินใจและแบบจำลองกลุ่มประชากรเปลี่ยนหลักฐานเป็นการตัดสินใจอย่างไร” แสดงว่าการวิเคราะห์นี้อยู่ตรงไหนในแบบจำลองเต็ม บทความเรื่อง “ความบังเอิญ อคติ และปัจจัยกวน” แยกความบังเอิญ ซึ่งเส้นโค้งเหล่านี้อธิบาย ออกจากอคติ ซึ่งการรันกี่ครั้งก็ขจัดไม่ได้
ลองใช้ในแอป
เปิดแอปแบบจำลองมาร์คอฟของเราเพื่อลองเปลี่ยนค่าข้อมูลนำเข้าและดูผลลัพธ์เปลี่ยนตาม ต้องเข้าสู่ระบบและใช้แผน Pro หรือ Max
แอปเริ่มจากค่าตัวอย่างของแอปเอง ตัวเลขในแอปจึงไม่ตรงกับบทความนี้
เอกสารอ้างอิง
- Briggs Andrew H., Weinstein Milton C., Fenwick Elisabeth A.L., Karnon Jonathan, Sculpher Mark J., Paltiel A. David. Model Parameter Estimation and Uncertainty: A Report of the ISPOR-SMDM Modeling Good Research Practices Task Force-6. Value in Health. 2012;15(6):835-842. doi:10.1016/j.jval.2012.04.014. https://api.crossref.org/works/10.1016/j.jval.2012.04.014
- Andrew Briggs, Karl Claxton, Mark Sculpher. Decision Modelling for Health Economic Evaluation (Handbooks for Health Economic Evaluation). Oxford University Press; 2006. ISBN 9780198526629. https://openlibrary.org/isbn/9780198526629.json
- Jalal Hawre, Pechlivanoglou Petros, Krijkamp Eline, Alarid-Escudero Fernando, Enns Eva, Hunink M. G. Myriam. An Overview of R in Health Decision Sciences. Medical Decision Making. 2017;37(7):735-746. doi:10.1177/0272989x16686559. https://api.crossref.org/works/10.1177/0272989x16686559
- Fenwick Elisabeth, Steuten Lotte, Knies Saskia, Ghabri Salah, Basu Anirban, Murray James F., Koffijberg Hendrik (Erik), Strong Mark, Sanders Schmidler Gillian D., Rothery Claire. Value of Information Analysis for Research Decisions-An Introduction: Report 1 of the ISPOR Value of Information Analysis Emerging Good Practices Task Force. Value in Health. 2020;23(2):139-150. doi:10.1016/j.jval.2020.01.001. https://api.crossref.org/works/10.1016/j.jval.2020.01.001
ลำดับการเรียนในชุดนี้
- คู่มือชุดบทความการสร้างแบบจำลองเศรษฐศาสตร์สุขภาพ: จากหลักฐานสู่การตัดสินใจ
- การเลือกแบบจำลองเพื่อการตัดสินใจ: ให้เส้นทางทางคลินิกกำหนดโครงสร้าง
- ข้อมูลนำเข้าของแบบจำลองที่ตรวจสอบได้: สร้างตารางพารามิเตอร์ที่สืบย้อนถึงที่มา
- การค้นหาข้อมูลนำเข้าของแบบจำลองจากวรรณกรรม: เลือกค่าประมาณให้ตรงกับคำถาม
- อัตรา ความน่าจะเป็น และความน่าจะเป็นของการเปลี่ยนสถานะ: คำนวณโดยไม่หลงหน่วยเวลา
- สร้างแผนภูมิต้นไม้การตัดสินใจทีละขั้น: ตามรอยผลลัพธ์ ต้นทุน และสุขภาพ
- สร้างแบบจำลองมาร์คอฟแบบกลุ่มประชากรทีละขั้น: ติดตามประชากรผ่านเวลา
- การวิเคราะห์ผลกระทบด้านงบประมาณในทางปฏิบัติ: นับคนให้ชัดก่อนคำนวณเงิน
- ICER และผลประโยชน์สุทธิในรูปตัวเงินเมื่อมีหลายทางเลือก: หาขอบเขตทางเลือกที่มีประสิทธิภาพ
- การวิเคราะห์ความไวแบบอาศัยความน่าจะเป็น: จากการแจกแจงพารามิเตอร์สู่ CEAC (บทความนี้)
- การประเมินความสมเหตุสมผลและตรวจสอบแบบจำลอง: ทดสอบคำถาม สมการ และโค้ด
- มูลค่าคาดหมายของข้อมูลสมบูรณ์: หลักฐานที่ดีขึ้นอาจมีมูลค่าเพียงใด
ประเด็นสำคัญ
- ให้ข้อมูลนำเข้าที่ไม่แน่นอนแต่ละตัวมีเส้นโค้งที่เหมาะกับมัน risk ratio ใช้เส้นโค้งรูประฆังบนสเกลลอการิทึม อัตราใช้เนินระหว่าง 0 ถึง 1 ต้นทุนใช้เส้นโค้งเหนือ 0 และแถวของโอกาสใช้การสุ่มทั้งแถว
- รันแบบจำลองซ้ำด้วยค่าสุ่มใหม่จากทุกเส้นโค้ง ที่เพดาน 160,000 บาทต่อ QALY ทางเลือก C ชนะในราว 599 จาก 1,000 ครั้ง คิดเป็นความน่าจะเป็น 0.60
- รายงานการแกว่งด้วย เมื่อรัน 1,000 ครั้ง สัดส่วน 0.60 แม่นราว 3.0 จุดเปอร์เซ็นต์ทั้งสองทาง
- นับซ้ำที่เพดานทุกระดับ เส้นโค้งอ่านได้ 0.50 ที่ 150,000 บาท และ 0.60 ที่ 160,000 บาท แล้วเลือกทางเลือกที่มีค่าเฉลี่ยสูงกว่า ซึ่งคือ C เมื่อเพดานสูงกว่า 150,000 บาท
อ่านต่อในวิกิ: ต้นไม้การตัดสินใจ แบบจำลองมาร์คอฟ และการวิเคราะห์ความไวเชิงความน่าจะเป็น: แบบจำลองแปลงหลักฐานเป็นการตัดสินใจได้อย่างไร · ความบังเอิญ อคติ และปัจจัยกวน: เหตุใดความสัมพันธ์ที่พบจึงยังไม่ใช่ผลของการรักษา