การประเมินความสมเหตุสมผลและตรวจสอบแบบจำลอง: ทดสอบคำถาม สมการ และโค้ด

On this page
Read the English version
บทคัดย่อ
ทีมของโรงพยาบาลแห่งหนึ่งต้องบอกคณะกรรมการยาว่าแบบจำลองของเพื่อนร่วมงาน ซึ่งติดตามผู้ป่วยโรคเรื้อรัง 1,000 คน เชื่อถือได้หรือไม่ แบบจำลองย้ายคนระหว่างสถานะสุขภาพดี ป่วย และเสียชีวิตปีละครั้งเป็นเวลา 4 ปี และรายงานผลเป็น 3,655.875 ปีชีวิต มีสองคำถามที่ต้องถามก่อน แบบจำลองสร้างถูกหรือไม่ คือการคำนวณทำตามที่ตั้งใจหรือเปล่า นี่คือการตรวจสอบแบบจำลอง (verification) และเป็นแบบจำลองที่เหมาะกับผู้ป่วยกลุ่มนี้และการตัดสินใจนี้หรือไม่ นี่คือการประเมินความสมเหตุสมผล (validation) การตรวจสี่แบบที่รู้คำตอบล่วงหน้าใช้ตอบคำถามแรก ผลรวมโอกาสในทุกแถวเท่ากับ 1 และคน 1,000 คนยังคงเป็น 1,000 คน ปีที่สองที่คำนวณด้วยมือได้ 640, 247.5 และ 112.5 เท่ากับแบบจำลอง เมื่อให้น้ำหนัก 1 แก่ทุกสถานะที่มีชีวิต ยอดรวมที่ถ่วงด้วยคุณภาพชีวิตจะเท่ากับปีชีวิต และเมื่อปิดการเสียชีวิตจะไม่มีใครเสียชีวิต ตารางที่เติมผิดทางไม่ผ่านการตรวจทันที และทำให้กลุ่มคนหดจาก 1,000 เหลือ 409.6 คน ส่วนการประเมินความสมเหตุสมผลต้องอาศัยแพทย์ ที่มาของข้อมูล และข้อมูลภายนอก
แบบจำลองของเพื่อนร่วมงาน ผู้ป่วย 1,000 คน และตัวเลขหนึ่งตัว
ทีมประเมินเทคโนโลยีด้านสุขภาพของโรงพยาบาลแห่งหนึ่งต้องให้คำแนะนำแก่คณะกรรมการยาในสัปดาห์หน้า เพื่อนร่วมงานคนหนึ่งสร้างแบบจำลองของผู้ป่วยโรคเรื้อรัง 1,000 คน และแบบจำลองรายงานว่ากลุ่มนี้มีปีชีวิตรวม 3,655.875 ปีในช่วง 4 ปีข้างหน้า ปีชีวิตคือจำนวนปีที่กลุ่มมีชีวิตอยู่ โดยนับหนึ่งปีให้ทุกคนที่ยังมีชีวิตเมื่อต้นปีแต่ละปี คณะกรรมการจะนำยอดนี้ไปเทียบกับผลของแบบจำลองเดียวกันเมื่อใช้การรักษาใหม่ ตัวเลขทั้งหมดเป็นตัวอย่างสมมติเพื่อการสอน
แบบจำลองมีสถานะสุขภาพสามสถานะ คือสุขภาพดี (Well) ป่วย (Ill) และเสียชีวิต (Dead) ผู้ป่วยทั้ง 1,000 คนเริ่มที่สถานะสุขภาพดี ในแต่ละปี คนที่สุขภาพดีมีโอกาสคงสุขภาพดี 0.80 ป่วย 0.15 และเสียชีวิต 0.05 ส่วนคนที่ป่วยมีโอกาสคงป่วย 0.85 และเสียชีวิต 0.15 ไม่มีใครออกจากสถานะเสียชีวิต และคนที่ป่วยแล้วไม่กลับมาสุขภาพดีอีก ข้อนี้เป็นข้อสมมติที่ต้องถามแพทย์
แบบจำลองชนิดนี้มีชื่อเรียก แบบจำลองมาร์คอฟ (Markov model) คือแบบจำลองที่ติดตามกลุ่มคนที่ย้ายระหว่างสถานะสุขภาพไม่กี่สถานะทีละรอบ ในที่นี้หนึ่งรอบคือ 1 ปี ใน 1,000 คนที่เริ่มสุขภาพดี รอบแรกมี 800 คนยังสุขภาพดี 150 คนป่วย และ 50 คนเสียชีวิต อ่านบทเรียนเต็มได้ที่ สร้างแบบจำลองมาร์คอฟแบบกลุ่มประชากรทีละขั้น: ติดตามประชากรผ่านเวลา
ก่อนที่ใครจะใช้ 3,655.875 ปีชีวิตนี้ ทีมต้องตรวจแบบจำลองเสียก่อน บทความนี้แก้นิสัยที่พบบ่อย คือเห็นว่าแบบจำลองรันได้โดยไม่มีข้อความแจ้งข้อผิดพลาดและให้ตัวเลขที่ดูเป็นไปได้ แล้วสรุปว่าตรวจแล้ว
สองคำถาม: สร้างถูกหรือไม่ และใช่แบบจำลองที่เหมาะหรือไม่
คำถามแรกเป็นเรื่องของสมการและโค้ด ถ้ามีคน 1,000 คนเข้าสู่แบบจำลอง ทุกปีต้องนับได้ 1,000 คนเสมอ ทั้งคนที่มีชีวิตและเสียชีวิต และปีแรกต้องแบ่งคนเป็น 800, 150 และ 50 คน การตรวจว่าสมการและโค้ดทำตามที่ผู้สร้างตั้งใจเรียกว่าการตรวจสอบแบบจำลอง (verification)
คำถามที่สองเป็นเรื่องของผู้ป่วยและโลกจริง แบบจำลองอาจเก็บคนได้ครบแต่ยังผิดได้ เพราะโอกาสเสียชีวิต 0.05 มาจากผู้ป่วยที่อายุน้อยกว่า หรือเพราะผู้ป่วยจริงกลับมาหายได้ การพิจารณาว่าแบบจำลองน่าเชื่อถือพอสำหรับการตัดสินใจนี้และผู้ป่วยกลุ่มนี้หรือไม่ เรียกว่าการประเมินความสมเหตุสมผล (validation)
การตรวจสอบแบบจำลองทำให้จบได้ภายในแบบจำลอง เพราะการตรวจแต่ละแบบมีคำตอบที่รู้ล่วงหน้า ส่วนการประเมินความสมเหตุสมผลต้องใช้ความรู้จากนอกแบบจำลอง ได้แก่แพทย์ ที่มาของข้อมูลนำเข้า แบบจำลองอื่น และข้อมูลที่แบบจำลองไม่เคยเห็น ภาพด้านล่างวางสองคำถามนี้คู่กันสำหรับแบบจำลองของเพื่อนร่วมงาน
การตรวจที่หนึ่ง: ไม่มีใครเกิดเพิ่มหรือหายไป
เพื่อนร่วมงานเก็บโอกาสรายปีไว้ในตารางเล็ก ๆ ด้านล่าง แต่ละแถวคือที่ที่คนอยู่ตอนนี้ และแต่ละคอลัมน์คือที่ที่คนนั้นจะอยู่ในอีกหนึ่งปีต่อมา คนในแบบจำลองย้ายได้เพียงระหว่างสถานะ จะโผล่มาจากที่ไหนก็ไม่ได้ และจะหายไปเฉย ๆ ก็ไม่ได้
ตารางโอกาสรายปีของเพื่อนร่วมงาน
| ตอนนี้ (แถว) และอีกหนึ่งปี (คอลัมน์) | สุขภาพดี | ป่วย | เสียชีวิต | ผลรวมแถว |
|---|---|---|---|---|
| สุขภาพดี | 0.80 | 0.15 | 0.05 | 1 |
| ป่วย | 0 | 0.85 | 0.15 | 1 |
| เสียชีวิต | 0 | 0 | 1 | 1 |
สองส่วนที่รู้คำตอบล่วงหน้า
การตรวจที่หนึ่งนี้มีสองส่วนที่รู้คำตอบ แต่ละแถวต้องรวมได้ 1 เพราะคนที่สุขภาพดีต้องไปอยู่ที่ใดที่หนึ่งเมื่อสิ้นปี 0.80 + 0.15 + 0.05 = 1 แถวป่วย 0 + 0.85 + 0.15 = 1 และแถวเสียชีวิต 0 + 0 + 1 = 1 ยอดรวมของสุขภาพดี ป่วย และเสียชีวิตต้องคงที่ 1,000 คนทุกขอบปี คือตอนเริ่ม (ขอบปีที่ 0) และเมื่อสิ้นแต่ละปีของทั้ง 4 ปี หลังปีแรกแบ่งเป็น 800, 150 และ 50 คน หลังปีที่ 4 เป็น 409.6, 337.2 และ 253.2 คนตามตัวอย่างคำนวณด้านล่าง ซึ่งรวมได้ 1,000 คนเช่นเดิม
จำนวนคนมีทศนิยมเพราะแบบจำลองกลุ่มประชากรติดตามจำนวนคาดหมาย ซึ่งเป็นค่าเฉลี่ยของกลุ่มที่เป็นไปได้จำนวนมาก ตารางโอกาสนี้เรียกว่าเมทริกซ์การย้ายสถานะ (transition matrix) คนที่เสียชีวิตยังอยู่ในยอดนับ เพราะสถานะเสียชีวิตเป็นสถานะดูดซับ (absorbing state) คือสถานะที่เข้าแล้วไม่มีใครออก การตรวจยอดรวมนี้เรียกว่าการตรวจการคงจำนวนคน (conservation check)
ตรวจผลรวมแถวและยอดรวมใน R
# Illustrative colleague's model: 1,000 people in Well, Ill and Dead, four yearly cycles.
# Writes: nothing to disk; the results print in the console.
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
P <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (i in seq_len(cycles)) trace[i + 1, ] <- trace[i, ] %*% P
cat("Transition row sums:", rowSums(P), "\n")
cat("Mass at boundaries 0 to 4 (people):", rowSums(trace), "\n")
cat("Maximum conservation error (people):", max(abs(rowSums(trace) - sum(start))), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
Transition row sums: 1 1 1
Mass at boundaries 0 to 4 (people): 1000 1000 1000 1000 1000
Maximum conservation error (people): 0
After year 4 Well Ill Dead (people): 409.6 337.2 253.2
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
การตรวจที่สอง: คำนวณด้วยมือ ติดตามกลุ่มคนทีละปี
เส้นทางที่สองไปสู่ตัวเลขชุดเดียวกันช่วยจับตารางที่อ่านกลับด้าน ให้เขียนลูกศรแต่ละเส้นของแบบจำลองเป็นการคูณหนึ่งครั้ง แล้วรวมสิ่งที่มาถึงแต่ละสถานะ
-
ปีแรก
\[ 1000 \times (0.80,\ 0.15,\ 0.05) = (800,\ 150,\ 50) \]
เริ่มจาก 1,000 คนในสถานะสุขภาพดี สุขภาพดีเก็บไว้ 1,000 × 0.80 = 800 คน ส่งไปป่วย 1,000 × 0.15 = 150 คน และส่งไปเสียชีวิต 1,000 × 0.05 = 50 คน
-
ปีที่สอง สุขภาพดี
\[ 800 \times 0.80 = 640 \]
มีเพียงคนที่สุขภาพดีอยู่แล้วที่คงอยู่ได้ เพราะไม่มีใครหายจากการป่วย 800 × 0.80 = 640 คน
-
ปีที่สอง ป่วย
\[ 120 + 127.5 = 247.5 \]
สถานะป่วยรับคนจากสุขภาพดีเพิ่ม 800 × 0.15 = 120 คน และเก็บคนเดิมไว้ 150 × 0.85 = 127.5 คน ตัวเลข 127.5 เป็นจำนวนคาดหมาย จึงคงทศนิยมไว้
-
ปีที่สอง เสียชีวิต
\[ 50 + 40 + 22.5 = 112.5 \]
สถานะเสียชีวิตเก็บ 50 คนเดิมไว้ รับจากสุขภาพดี 800 × 0.05 = 40 คน และจากป่วย 150 × 0.15 = 22.5 คน
-
รวมปีที่สอง
\[ 640 + 247.5 + 112.5 = 1000 \]
เส้นทางด้วยมือได้ 640, 247.5 และ 112.5 คน รวมยังเป็น 1,000 คน จึงไม่มีใครหายไปในปีที่สองเช่นกัน
-
ปีที่สาม ทำแบบเดิม
\[ 96 + 210.375 = 306.375 \]
สุขภาพดีเก็บไว้ 640 × 0.80 = 512 คน สถานะป่วยรับคนเพิ่ม 640 × 0.15 = 96 คน และเก็บคนเดิมไว้ 247.5 × 0.85 = 210.375 คน รวมเป็น 306.375 คน สถานะเสียชีวิตรับเพิ่ม 640 × 0.05 = 32 คน และ 247.5 × 0.15 = 37.125 คน จาก 112.5 คนเดิม รวมเป็น 181.625 คน
-
ปีที่สี่
\[ 1000 - 409.6 - 337.2 = 253.2 \]
สุขภาพดีเก็บไว้ 512 × 0.80 = 409.6 คน สถานะป่วยมี 512 × 0.15 + 306.375 × 0.85 = 337.2 คนเมื่อปัดเศษ และสถานะเสียชีวิตมีส่วนที่เหลือ 1,000 - 409.6 - 337.2 = 253.2 คน
ผลลัพธ์: การคูณเมทริกซ์ได้จำนวนเดียวกัน ผลต่างในปีที่สองเป็น 0 คน และหลังปีที่ 4 ได้ 409.6, 337.2 และ 253.2 คนเช่นกัน ถ้าสองเส้นทางได้ไม่ตรงกัน แปลว่าเส้นทางหนึ่งอ่านตารางกลับด้าน
ชื่อเรียก: การคำนวณซ้ำอีกทางหนึ่ง
การคำนวณส่วนเล็ก ๆ ของแบบจำลองซ้ำอีกครั้งด้วยวิธีที่แยกจากกัน เรียกว่าการคำนวณซ้ำอีกทางหนึ่ง (independent calculation) วิธีนี้จับเมทริกซ์ที่อ่านผิดทิศหรือดัชนีที่ผิดได้ แต่ยังใช้ข้อมูลนำเข้าชุดเดิม จึงบอกไม่ได้ว่า 0.15 เป็นโอกาสป่วยรายปีที่ถูกต้องหรือไม่
ตรวจปีที่สองด้วยมือใน R
# The second year written out by hand, one arrow at a time, then compared with the matrix.
# Writes: nothing to disk; the results print in the console.
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
P <- matrix(transition, nrow = length(start), byrow = TRUE)
year_one <- start[1] * P[1, ]
ill_parts <- c(year_one[1] * P[1, 2], year_one[2] * P[2, 2])
dead_parts <- c(year_one[3], year_one[1] * P[1, 3], year_one[2] * P[2, 3])
year_two <- c(year_one[1] * P[1, 1], sum(ill_parts), sum(dead_parts))
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - as.vector(start %*% P %*% P))), "\n")
First year by hand Well Ill Dead (people): 800 150 50
Second-year Ill: from Well, stayed Ill (people): 120 127.5
Second-year Dead: already, from Well, from Ill (people): 50 40 22.5
Second year by hand Well Ill Dead (people): 640 247.5 112.5
Largest gap to the matrix (people): 0
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
ตรวจปีที่สามและปีที่สี่ใน R
# The third and fourth years the same way, starting from the second year's 640, 247.5 and 112.5.
# Writes: nothing to disk; the results print in the console.
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
P <- matrix(transition, nrow = length(start), byrow = TRUE)
year_two <- as.vector(start %*% P %*% P)
ill_three <- c(year_two[1] * P[1, 2], year_two[2] * P[2, 2])
dead_three <- c(year_two[1] * P[1, 3], year_two[2] * P[2, 3])
year_three <- as.vector(year_two %*% P)
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", as.vector(year_three %*% P)), "\n")
Third-year Ill: from Well, stayed Ill (people): 96 210.375
Third-year new deaths: from Well, from Ill (people): 32 37.125
Third year Well Ill Dead (people): 512 306.375 181.625
Fourth year Well Ill Dead (people): 409.6 337.2 253.2
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
การตรวจที่สาม: กรณีที่รู้คำตอบล่วงหน้า
คนแต่ละคนที่ยังมีชีวิตเมื่อต้นปีนับเป็นการมีชีวิตหนึ่งปี คนที่มีชีวิตเมื่อต้นปีที่ 1 ถึง 4 คือ 1,000, 950, 887.5 และ 818.375 คน (950 มาจาก 800 + 150, 887.5 มาจาก 640 + 247.5 และ 818.375 มาจาก 512 + 306.375) ยอดรวมจึงเป็น 3,655.875 ปีชีวิต
แบบจำลองทางเศรษฐศาสตร์มักถ่วงน้ำหนักแต่ละปีด้วยคุณภาพ เพราะหนึ่งปีที่ป่วยมีค่าต่อผู้ป่วยน้อยกว่าหนึ่งปีที่สุขภาพดี ปีสุขภาวะ (quality-adjusted life year, QALY) คือปีชีวิตที่ถ่วงน้ำหนักด้วยคุณภาพ ตั้งแต่ 1 สำหรับสุขภาพสมบูรณ์ถึง 0 สำหรับการเสียชีวิต การอยู่ 10 ปีในสถานะที่มีค่าคุณภาพชีวิต 0.70 เท่ากับ 7.0 QALY ถ้ากลับมาสุขภาพสมบูรณ์ตลอดช่วงนั้น จะได้เพิ่มอีก 3.0 QALY อ่านบทเรียนเต็มได้ที่ ปีสุขภาวะ ปีสุขภาวะที่สูญเสีย และค่าอรรถประโยชน์: เรากำลังวัดอะไรกันแน่
ทีนี้ให้น้ำหนัก 1 แก่สุขภาพดีและป่วย และน้ำหนัก 0 แก่เสียชีวิต ทุกปีที่มีชีวิตจะนับเต็ม ยอด QALY จึงต้องเท่ากับ 3,655.875 ปีชีวิตพอดี ยอดทั้งสองคือ 3,655.875 และ 3,655.875 ผลต่าง 0 การตั้งค่าแบบนี้ ซึ่งรู้คำตอบที่ถูกก่อนรันแบบจำลอง เรียกว่ากรณีขอบเขต (limiting case)
เทียบปีชีวิตกับ QALY เมื่อน้ำหนักเป็น 1 ใน R
# Count life-years, then weighted years with weight 1 for Well and Ill: the two totals must match.
# Each yearly cycle counts one year for every person alive at its start.
# Writes: nothing to disk; the results print in the console.
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
P <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (i in seq_len(cycles)) trace[i + 1, ] <- trace[i, ] %*% P
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(sum(alive_start), weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - sum(alive_start), "\n")
Alive at the start of years 1 to 4 (people): 1000 950 887.5 818.375
Life-years, weight-1 years: 3655.875 3655.875
Weighted minus life-years: 0
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
นับยอดทั้งสองแบบเดียวกัน
ความเท่ากันนี้จริงเฉพาะเมื่อนับยอดทั้งสองแบบเดียวกัน และเรื่องเวลาคือจุดที่ทำให้ความเท่ากันนี้พังได้ง่ายที่สุด
การปรับลดค่าตามเวลา (discounting) คือการแปลงต้นทุนหรือผลทางสุขภาพในอนาคตเป็นมูลค่าวันนี้ โดยลดลงตามอัตราการปรับลดในแต่ละปี ที่อัตรา 3% ต้นทุน 1,834 บาทที่ต้องจ่ายในปีที่ 3 มีค่าเท่ากับ 1,678 บาทในวันนี้ อ่านบทเรียนเต็มได้ที่ ต้นทุนของใคร ณ ปีใด: มุมมองการวิเคราะห์ ต้นทุนค่าเสียโอกาส การปรับค่าเงินเฟ้อ และการปรับลดค่าตามเวลา
ตรวจตัวเลขทบทวนสองชุดใน R
# The two recaps the weight-1 check leans on: a weighted year, and a future amount valued today.
# Writes: nothing to disk; the results print in the console.
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
10 years at weight 0.70, gain: 7.0 3.0
Yearly rate back to today (%): 3
1,834 THB due in year 3, worth today (THB): 1,678
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
ความไม่ตรงกันที่การตรวจจับได้
สมมติว่าเพื่อนร่วมงานปรับลดค่าตามเวลาให้ QALY ปีละ 3% แต่ปล่อยปีชีวิตไว้ตามที่นับ จำนวนของแต่ละปีจะถูกหารด้วย 1.03 หนึ่งครั้งต่อทุกปีที่ห่างออกไป 1,000 / 1.03 = 970.874, 950 / 1.03² = 895.466, 887.5 / 1.03³ = 812.188 และ 818.375 / 1.03⁴ = 727.116 รวมได้ 3,405.644 การตรวจจึงไม่ผ่านโดยต่างกัน 3,655.875 - 3,405.644 = 250.231 ทั้งที่น้ำหนักทุกค่าถูกต้อง สาเหตุคือการจัดการเรื่องเวลาที่ไม่ตรงกัน จึงต้องจัดการทั้งสองยอดแบบเดียวกัน ส่วนการตั้งน้ำหนัก 1 เป็นการทดสอบโค้ด ไม่ใช่การอ้างว่าผู้ป่วยมีสุขภาพสมบูรณ์
ใส่ความไม่ตรงกันเรื่องเวลาใน R
# Plant a mismatch: shrink the weight-1 years for time at 3% a year, leave the life-years as counted.
# Writes: nothing to disk; the results print in the console.
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
yearly_factor <- 1.03
P <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (i in seq_len(cycles)) trace[i + 1, ] <- trace[i, ] %*% P
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
each_year <- alive_start / yearly_factor^seq_len(cycles)
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
shrunk <- sum(each_year)
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, sum(alive_start) - shrunk)), "\n")
Each year shrunk (years): 970.874 895.466 812.188 727.116
Shrunk weight-1 years, gap: 3405.644 250.231
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
การตรวจที่สี่: ปิดการเสียชีวิต
ถ้าไม่มีใครเสียชีวิตได้ ทั้ง 1,000 คนต้องยังมีชีวิตหลัง 4 ปี วิธีปิดการเสียชีวิตคือย้ายโอกาสเสียชีวิตแต่ละค่ากลับไปเป็นโอกาสอยู่ที่เดิม แถวสุขภาพดีจึงเป็น 0.85, 0.15 และ 0 และแถวป่วยเป็น 0, 1 และ 0 ส่วนแถวเสียชีวิตคงเดิม
จำนวนคนที่มีชีวิตจะคงที่ 1,000 คนทุกปี และจำนวนผู้เสียชีวิตคงที่ 0 การลบโอกาสเสียชีวิตทิ้งเฉย ๆ เป็นข้อผิดพลาดอีกแบบหนึ่ง เพราะแถวสุขภาพดีจะรวมไม่ได้ 1 และคนจะหายไป
การตั้งค่านี้ก็เป็นกรณีขอบเขตเช่นกัน การตรวจนี้จับการส่งคนที่เสียชีวิตไปอยู่ผิดสถานะ หรือการส่งคนออกจากสถานะเสียชีวิต แต่บอกไม่ได้ว่า 0.05 เป็นโอกาสเสียชีวิตรายปีที่ถูกต้องของผู้ป่วยกลุ่มนี้หรือไม่
ปิดการเสียชีวิตใน R
# Writes: nothing to disk; the results print in the console.
start <- c(1000,0,0)
transition <- c(0.80,0.15,0.05,0,0.85,0.15,0,0,1)
cycles <- 4
P_zero <- matrix(transition, nrow = length(start), byrow = TRUE)
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (i in seq_len(cycles)) trace_zero[i + 1, ] <- trace_zero[i, ] %*% P_zero
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
Zero-mortality living people: 1000 1000 1000 1000 1000
Zero-mortality deaths: 0 0 0 0 0
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
แบบจำลองที่ผิดไม่ผ่านการตรวจ
ทีนี้ลองทำให้แบบจำลองของเพื่อนร่วมงานผิดโดยตั้งใจ โอกาสทั้งเก้าค่าถูกพิมพ์เป็นรายการเดียวเรียงทีละแถว 0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1 ถ้าเติมตารางทีละคอลัมน์แทน สถานะต้นทางกับปลายทางจะสลับกัน แถวทั้งสามกลายเป็น 0.80, 0 และ 0 (รวม 0.8) ต่อด้วย 0.15, 0.85 และ 0 (รวม 1) และ 0.05, 0.15 และ 1 (รวม 1.2)
ตอนนี้สุขภาพดีเก็บคนไว้ 0.80 ทุกปีและไม่ส่งใครไปไหน ปีแรกจึงเหลือ 1,000 × 0.80 = 800 คน ทั้งหมดอยู่ในสถานะสุขภาพดี จากนั้นกลุ่มคนหดเป็น 800 × 0.80 = 640, แล้วเป็น 512 และ 409.6 คนในสี่ปี โค้ดรันได้โดยไม่มีข้อความแจ้งข้อผิดพลาด แต่การตรวจผลรวมแถวและการตรวจการคงจำนวนคนไม่ผ่านทันที
ตัวเลข 409.6 ตรงกับจำนวนคนสุขภาพดีหลังปีที่ 4 ของแบบจำลองที่ถูกเพียงเพราะบังเอิญ เพราะทั้งสองกรณีสุขภาพดีเก็บคนไว้ 0.80 ต่อปี คือ 1,000 × 0.80⁴ แต่ในที่นี้ตัวเลขนี้คือทุกคนที่เหลืออยู่ เพราะคนอื่นหายไปหมดแล้ว
ทำให้เมทริกซ์ผิดโดยตั้งใจใน R
# Break the model on purpose: fill the matrix by column instead of by row.
# Rows should be "from" states and columns "to" states; byrow = FALSE swaps them.
start <- c(1000,0,0)
transition <- c(0.80,0.15,0.05,0,0.85,0.15,0,0,1)
cycles <- 4
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (i in seq_len(cycles)) trace_bad[i + 1, ] <- trace_bad[i, ] %*% P_reversed
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
Reversed-matrix row sums: 0.8 1 1.2
Reversed-matrix mass at boundaries 0 to 4 (people): 1000 800 640 512 409.6
Reversed-matrix first cycle Well Ill Dead (people): 800 0 0
ต้องใช้ R ≥ 4.5.2 · tibble 3.3.1 · dplyr 1.2.1 · ใช้ได้กับ R 4.6.1 · ดาวน์โหลด model-validation-and-verification.R
สคริปต์ฉบับเต็ม
# Model Validation and Verification: Test the Question, Equations and Code
# Check a colleague's cohort model of 1,000 people before a committee uses its life-years.
# ตรวจแบบจำลองกลุ่มประชากร 1,000 คนของเพื่อนร่วมงาน ก่อนที่คณะกรรมการจะใช้ปีชีวิตจากแบบจำลองนั้น
# Requires: R >= 4.5.2
# Tested: works in R 4.6.1
# Writes: no files; console results only.
# tibble 3.3.1 (stable)
# dplyr 1.2.1 (stable)
# install.packages(c("tibble", "dplyr"))
# update.packages(oldPkgs = c("tibble", "dplyr"))
# remotes::install_version("tibble", "3.3.1")
# remotes::install_version("dplyr", "1.2.1")
stopifnot(getRversion() >= "4.5.2")
library(tibble) # Keep the yearly counts in a named table.
library(dplyr) # Add the totals and the conservation gap to each row.
stopifnot(as.character(utils::packageVersion("tibble")) == "3.3.1")
stopifnot(as.character(utils::packageVersion("dplyr")) == "1.2.1")
#%%@note 1. Write the illustrative inputs once
# States are Well, Ill and Dead. Rows are where people are now, columns where they go next.
# สถานะคือ Well, Ill และ Dead แถวคือที่ที่คนอยู่ตอนนี้ คอลัมน์คือที่ที่จะไปต่อ
start <- c(1000, 0, 0)
transition <- c(0.80, 0.15, 0.05, 0, 0.85, 0.15, 0, 0, 1)
cycles <- 4
weight_one <- c(1, 1, 0)
years_in_state <- 10
state_weight <- 0.70
discount_rate <- 0.03
yearly_factor <- 1.03
one_decimal <- function(value) sprintf("%.1f", value)
big <- function(value) formatC(value, format = "f", digits = 0, big.mark = ",")
#%%@note 2. Follow the cohort and check that nobody appears or vanishes
# Each row of the table is one year boundary; the gap must stay at zero people.
# แต่ละแถวของตารางคือขอบปีหนึ่ง ผลต่างต้องเป็นศูนย์คนตลอด
transition_matrix <- matrix(transition, nrow = length(start), byrow = TRUE)
trace <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace[cycle_index + 1, ] <- trace[cycle_index, ] %*% transition_matrix
}
validation_table <- tibble::tibble(
boundary = seq_len(nrow(trace)) - 1, # Cohort model: year boundary.
well = trace[, 1], # Cohort model: people in Well.
ill = trace[, 2], # Cohort model: people in Ill.
dead = trace[, 3] # Cohort model: people in Dead.
) |>
dplyr::mutate(
total = well + ill + dead, # Cohort model: everyone, the dead included.
error = total - sum(start) # Cohort model: people gained or lost.
)
stopifnot(max(abs(validation_table$error)) < 1e-9)
#%%@note 3. Write the second year out by hand, then the third
# One product per arrow, so a flipped matrix would give different counts.
# หนึ่งผลคูณต่อหนึ่งลูกศร ถ้าเมทริกซ์กลับด้านจะได้จำนวนคนต่างไป
year_one <- start[1] * transition_matrix[1, ]
ill_parts <- c(
year_one[1] * transition_matrix[1, 2],
year_one[2] * transition_matrix[2, 2]
)
dead_parts <- c(
year_one[3],
year_one[1] * transition_matrix[1, 3],
year_one[2] * transition_matrix[2, 3]
)
year_two <- c(year_one[1] * transition_matrix[1, 1], sum(ill_parts), sum(dead_parts))
ill_three <- c(year_two[1] * transition_matrix[1, 2], year_two[2] * transition_matrix[2, 2])
dead_three <- c(year_two[1] * transition_matrix[1, 3], year_two[2] * transition_matrix[2, 3])
year_three <- as.vector(year_two %*% transition_matrix)
year_four <- as.vector(year_three %*% transition_matrix)
#%%@note 4. Count life-years and weight-1 years on the same clock
# Each yearly cycle counts one year for every person alive at its start.
# คนที่ยังมีชีวิตเมื่อต้นปีนับคนละหนึ่งปี
alive_start <- rowSums(trace[seq_len(cycles), 1:2])
life_years <- sum(alive_start)
weighted_years <- sum(trace[seq_len(cycles), ] %*% weight_one)
quality_years <- years_in_state * state_weight
gain <- years_in_state - quality_years
stopifnot(abs(yearly_factor - (1 + discount_rate)) < 1e-12)
each_year <- alive_start / yearly_factor^seq_len(cycles)
shrunk <- sum(each_year)
#%%@note 5. Switch death off without losing anyone
# Each living state's death chance goes back to staying in that state.
# โอกาสเสียชีวิตของแต่ละสถานะที่มีชีวิตย้ายกลับไปเป็นโอกาสอยู่สถานะเดิม
P_zero <- transition_matrix
alive <- seq_len(length(start) - 1)
P_zero[alive, alive] <- P_zero[alive, alive] + diag(P_zero[alive, length(start)])
P_zero[alive, length(start)] <- 0
trace_zero <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_zero[cycle_index + 1, ] <- trace_zero[cycle_index, ] %*% P_zero
}
#%%@note 6. Break the model on purpose
# Filling the matrix by column swaps the from and to states.
# การเติมเมทริกซ์ตามคอลัมน์สลับสถานะต้นทางกับปลายทาง
P_reversed <- matrix(transition, nrow = length(start), byrow = FALSE)
trace_bad <- matrix(rep(start, cycles + 1), ncol = length(start), byrow = TRUE)
for (cycle_index in seq_len(cycles)) {
trace_bad[cycle_index + 1, ] <- trace_bad[cycle_index, ] %*% P_reversed
}
#%%@note 7. Numbers the article quotes
# Print in the order of the short boxes in the article.
# พิมพ์ตามลำดับกล่องสั้นในบทความ
cat("Transition row sums:", rowSums(transition_matrix), "\n")
cat("Mass at boundaries 0 to 4 (people):", validation_table$total, "\n")
cat("Maximum conservation error (people):", max(abs(validation_table$error)), "\n")
cat("After year 4 Well Ill Dead (people):", sprintf("%.1f", trace[cycles + 1, ]), "\n")
cat("First year by hand Well Ill Dead (people):", year_one, "\n")
cat("Second-year Ill: from Well, stayed Ill (people):", ill_parts, "\n")
cat("Second-year Dead: already, from Well, from Ill (people):", dead_parts, "\n")
cat("Second year by hand Well Ill Dead (people):", year_two, "\n")
cat("Largest gap to the matrix (people):", max(abs(year_two - trace[3, ])), "\n")
cat("Third-year Ill: from Well, stayed Ill (people):", ill_three, "\n")
cat("Third-year new deaths: from Well, from Ill (people):", dead_three, "\n")
cat("Third year Well Ill Dead (people):", year_three, "\n")
cat("Fourth year Well Ill Dead (people):", sprintf("%.1f", year_four), "\n")
cat("Alive at the start of years 1 to 4 (people):", alive_start, "\n")
cat("Life-years, weight-1 years:", sprintf("%.3f", c(life_years, weighted_years)), "\n")
cat("Weighted minus life-years:", weighted_years - life_years, "\n")
cat("10 years at weight 0.70, gain:", one_decimal(c(quality_years, gain)), "\n")
cat("Yearly rate back to today (%):", 100 * discount_rate, "\n")
cat("1,834 THB due in year 3, worth today (THB):", big(1834 / (1 + discount_rate)^3), "\n")
cat("Each year shrunk (years):", sprintf("%.3f", each_year), "\n")
cat("Shrunk weight-1 years, gap:", sprintf("%.3f", c(shrunk, life_years - shrunk)), "\n")
cat("Zero-mortality living people:", rowSums(trace_zero[, alive]), "\n")
cat("Zero-mortality deaths:", trace_zero[, length(start)], "\n")
cat("Reversed-matrix row sums:", rowSums(P_reversed), "\n")
cat("Reversed-matrix mass at boundaries 0 to 4 (people):", rowSums(trace_bad), "\n")
cat("Reversed-matrix first cycle Well Ill Dead (people):", trace_bad[2, ], "\n")
การประเมินความสมเหตุสมผล: ใช่แบบจำลองที่เหมาะกับผู้ป่วยกลุ่มนี้หรือไม่
การตรวจทั้งสี่แบบผ่านหมด แต่ไม่มีแบบใดบอกว่าแบบจำลองเหมาะกับผู้ป่วยของโรงพยาบาล ให้เริ่มที่เส้นทางของโรค ในแบบจำลองนี้ไม่มีใครหายจากการป่วย ให้นำสามสถานะไปให้แพทย์ดู แล้วถามว่าผู้ป่วยจริงอาจเดินเส้นทางที่แบบจำลองไม่อนุญาตหรือไม่ ความสอดคล้องของโครงสร้างกับตัวโรคเรียกว่าความสมเหตุสมผลเชิงแนวคิด (conceptual validity)
จากนั้นตรวจข้อมูลนำเข้าแต่ละค่าเทียบกับที่มา 0.05 ต้องเป็นโอกาสเสียชีวิตรายปีของผู้ป่วยแบบนี้ ไม่ใช่ความเสี่ยงในช่วงเวลาที่ยาวกว่า หรือตัวเลขจากกลุ่มผู้เข้าร่วมการทดลองที่อายุน้อยกว่า ความเข้ากันของตัวเลขกับประชากร หน่วยเวลา และคำถาม เรียกว่าความสมเหตุสมผลของข้อมูลนำเข้า (input validity) บทความเรื่องการค้นหาข้อมูลนำเข้าจากวรรณกรรมในชุดนี้ การค้นหาข้อมูลนำเข้าของแบบจำลองจากวรรณกรรม: เลือกค่าประมาณให้ตรงกับคำถาม อธิบายวิธีสืบตัวเลขแบบนี้กลับไปหาที่มา
แบบจำลองนี้ทำนายว่ามีผู้เสียชีวิต 253.2 คนต่อ 1,000 คนภายในปีที่ 4 และการตรวจอีกสองแบบต้องใช้สิ่งที่อยู่นอกแบบจำลองมาทดสอบตัวเลขนี้ การเปรียบเทียบข้ามแบบจำลอง (cross-model comparison) คือการรันแบบจำลองของอีกทีมด้วยข้อมูลนำเข้าชุดเดียวกัน ถ้าได้ผู้เสียชีวิตมากหรือน้อยกว่านี้มาก ต้องหาว่าแบบจำลองใดผิด การประเมินความสมเหตุสมผลภายนอก (external validation) คือการเทียบ 253.2 กับข้อมูลที่แบบจำลองไม่เคยใช้ เช่นทะเบียนผู้ป่วยที่คล้ายกัน การประเมินความแม่นยำในการทำนาย (predictive validation) ทำแบบเดียวกันกับข้อมูลที่เก็บหลังสร้างแบบจำลองเสร็จ เช่นผู้ป่วยที่ติดตามต่อจากนั้น
แบบตรวจที่ตีพิมพ์แล้วสองชุดช่วยได้ AdViSHE ของ Vemer และคณะ รวบรวมคำถามการประเมินความสมเหตุสมผลสำหรับผู้ใช้แบบจำลองและผู้ตัดสินใจในทุกด้านข้างต้น TECH-VER ของ Büyükkaramikli และคณะ รวบรวมการทดสอบทางเทคนิคแบบเดียวกับสี่การตรวจข้างต้น การทำเครื่องหมายครบไม่ได้ทำให้แบบจำลองน่าเชื่อถือ ให้ใช้แต่ละชุดเป็นแบบบันทึกว่าตรวจอะไรและพบอะไร
Eddy และคณะเชื่อมการประเมินความสมเหตุสมผลกับความโปร่งใส แบบจำลองที่คนอื่นรันซ้ำและเปิดดูได้ตรวจง่ายกว่า แต่การทำซ้ำได้ไม่ได้ทำให้ค่าทำนายแม่นยำ Jalal และคณะอธิบายว่า R เหมาะกับแบบจำลองแบบเปิดเช่นนี้
บันทึกการตรวจของแบบจำลองเพื่อนร่วมงาน
| การตรวจ | ผลที่คาดไว้ก่อนรัน | ผลที่ได้ | สิ่งที่ยังเปิดค้าง |
|---|---|---|---|
| แถวและยอดรวม | แต่ละแถวรวมได้ 1 มีคน 1,000 คนที่ขอบทุกปี | 1, 1 และ 1 มี 1,000 คนที่ขอบปี 0 ถึง 4 ผลต่าง 0 | 0.05 เป็นโอกาสเสียชีวิตที่ถูกหรือไม่ |
| คำนวณทีละปีด้วยมือ | ผลคูณหนึ่งครั้งต่อหนึ่งลูกศรให้จำนวนเท่ากับเมทริกซ์ | 640, 247.5 และ 112.5 ทั้งสองทาง ผลต่าง 0 | เส้นทางด้วยมือใช้ข้อมูลนำเข้าชุดเดิม |
| น้ำหนัก 1 | QALY เท่ากับปีชีวิตเมื่อนับทั้งสองแบบเดียวกัน | 3,655.875 และ 3,655.875 ผลต่าง 0 | ค่าน้ำหนักคุณภาพชีวิตจริงของผู้ป่วย |
| ไม่มีการเสียชีวิต | มีชีวิต 1,000 คนและเสียชีวิต 0 คนทุกปี | มีชีวิต 1,000 คนและเสียชีวิต 0 คนที่ขอบปี 0 ถึง 4 | โอกาสเสียชีวิตรายปีจริง |
| ข้อมูลภายนอก | แบบจำลอง: เสียชีวิต 253.2 คนต่อ 1,000 คนภายในปีที่ 4 | ยังไม่มีข้อมูลภายนอก | เปิดค้างจนกว่าจะเทียบกับทะเบียนผู้ป่วยที่คล้ายกัน |
เก็บบันทึกการตรวจที่ยังเปิดค้างได้
สำหรับการตรวจแต่ละแบบ ให้จดข้ออ้างที่ทดสอบ ผลที่คาดไว้ ผลจริง ตำแหน่งของหลักฐาน และสิ่งที่ยังเปิดค้าง ให้เขียนผลที่คาดไว้ก่อนรันการตรวจ เพื่อไม่ให้ผลที่ผิดคาดกลายเป็นความคาดหมายใหม่อย่างเงียบ ๆ และจดรุ่นของแบบจำลองกับชื่อผู้ทบทวนไว้ด้วย
การตรวจสอบแบบจำลองทั้งสี่แบบปิดแล้ว ส่วนการเทียบกับข้อมูลภายนอกยังเปิดค้าง เพราะยังไม่มีข้อมูลทะเบียนผู้ป่วยมาเทียบกับผู้เสียชีวิต 253.2 คน
การตรวจที่ไม่ผ่านคือข้อค้นพบที่ต้องอธิบาย ไม่ใช่เหตุผลให้ปรับข้อมูลนำเข้าจนผ่าน ให้บันทึกสาเหตุ วิธีแก้ และผลที่เปลี่ยนไป จากนั้นทีมจึงบอกคณะกรรมการได้ว่า 3,655.875 ปีชีวิตนี้ผ่านการทดสอบอะไรมาแล้ว และยังไม่ได้ทดสอบอะไร
คำศัพท์ที่ควรแยกให้ชัด
- Verification (การตรวจสอบแบบจำลอง)
- การตรวจว่าสมการและโค้ดทำตามที่ผู้สร้างตั้งใจ ในที่นี้คือคน 1,000 คนยังเป็น 1,000 คน
- Validation (การประเมินความสมเหตุสมผล)
- การพิจารณาว่าแบบจำลองน่าเชื่อถือพอสำหรับการตัดสินใจนี้และผู้ป่วยกลุ่มนี้หรือไม่ ในที่นี้คือ 0.05 เข้ากับผู้ป่วยหรือไม่
- Transition matrix (เมทริกซ์การย้ายสถานะ)
- ตารางโอกาสรายปี หนึ่งแถวต่อที่ที่คนอยู่ตอนนี้ และหนึ่งคอลัมน์ต่อที่ที่จะไป แต่ละแถวรวมได้ 1
- Absorbing state (สถานะดูดซับ)
- สถานะที่เข้าแล้วไม่มีใครออก ในแบบจำลองนี้คือสถานะเสียชีวิต
- Independent calculation (การคำนวณซ้ำอีกทางหนึ่ง)
- การคำนวณส่วนหนึ่งของแบบจำลองซ้ำด้วยวิธีที่แยกจากกัน ในที่นี้คือการคำนวณทีละปีด้วยมือเทียบกับเมทริกซ์
- Limiting case (กรณีขอบเขต)
- การตั้งค่าที่รู้คำตอบที่ถูกล่วงหน้า เช่นน้ำหนัก 1 (ได้ 3,655.875 ทั้งสองฝั่ง) หรือไม่มีการเสียชีวิต
- Cross-model comparison (การเปรียบเทียบข้ามแบบจำลอง)
- การรันแบบจำลองที่ทีมอื่นสร้างด้วยข้อมูลนำเข้าชุดเดียวกัน แล้วเทียบคำตอบ
- External validation (การประเมินความสมเหตุสมผลภายนอก)
- การเทียบค่าทำนาย เช่นผู้เสียชีวิต 253.2 คนต่อ 1,000 คนภายในปีที่ 4 กับข้อมูลที่แบบจำลองไม่เคยใช้
เชื่อมกับบทความพื้นฐาน
แบบจำลองกลุ่มประชากรแบบเดียวกับของเพื่อนร่วมงานแปลงหลักฐานเป็นการตัดสินใจได้อย่างไร อ่านได้ในบทความพื้นฐานเรื่องต้นไม้การตัดสินใจและแบบจำลองกลุ่มประชากร ส่วนหลักฐานเบื้องหลังข้อมูลนำเข้าอย่าง 0.05 แน่นอนเพียงใดและควรเลือกอย่างไร อ่านได้ในบทความพื้นฐานเรื่องระดับความเชื่อมั่นตามแนวทาง GRADE และการเลือกข้อมูลประสิทธิผล
ลองใช้ในแอป
เปิดแอปแบบจำลองมาร์คอฟของเราเพื่อลองเปลี่ยนค่าข้อมูลนำเข้าและดูผลลัพธ์เปลี่ยนตาม ต้องเข้าสู่ระบบและใช้แผน Pro หรือ Max
แอปเริ่มจากค่าตัวอย่างของแอปเอง ตัวเลขในแอปจึงไม่ตรงกับบทความนี้
เอกสารอ้างอิง
- Eddy David M., Hollingworth William, Caro J. Jaime, Tsevat Joel, McDonald Kathryn M., Wong John B.. Model Transparency and Validation: A Report of the ISPOR-SMDM Modeling Good Research Practices Task Force-7. Value in Health. 2012;15(6):843-850. doi:10.1016/j.jval.2012.04.012. https://api.crossref.org/works/10.1016/j.jval.2012.04.012
- Vemer P., Corro Ramos I., van Voorn G. A. K., Al M. J., Feenstra T. L.. AdViSHE: A Validation-Assessment Tool of Health-Economic Models for Decision Makers and Model Users. PharmacoEconomics. 2016;34(4):349-361. doi:10.1007/s40273-015-0327-2. https://api.crossref.org/works/10.1007/s40273-015-0327-2
- Büyükkaramikli Nasuh C., Rutten-van Mölken Maureen P. M. H., Severens Johan L., Al Maiwenn. TECH-VER: A Verification Checklist to Reduce Errors in Models and Improve Their Credibility. PharmacoEconomics. 2019;37(11):1391-1408. doi:10.1007/s40273-019-00844-y. https://api.crossref.org/works/10.1007/s40273-019-00844-y
- 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
ลำดับการเรียนในชุดนี้
- คู่มือชุดบทความการสร้างแบบจำลองเศรษฐศาสตร์สุขภาพ: จากหลักฐานสู่การตัดสินใจ
- การเลือกแบบจำลองเพื่อการตัดสินใจ: ให้เส้นทางทางคลินิกกำหนดโครงสร้าง
- ข้อมูลนำเข้าของแบบจำลองที่ตรวจสอบได้: สร้างตารางพารามิเตอร์ที่สืบย้อนถึงที่มา
- การค้นหาข้อมูลนำเข้าของแบบจำลองจากวรรณกรรม: เลือกค่าประมาณให้ตรงกับคำถาม
- อัตรา ความน่าจะเป็น และความน่าจะเป็นของการเปลี่ยนสถานะ: คำนวณโดยไม่หลงหน่วยเวลา
- สร้างแผนภูมิต้นไม้การตัดสินใจทีละขั้น: ตามรอยผลลัพธ์ ต้นทุน และสุขภาพ
- สร้างแบบจำลองมาร์คอฟแบบกลุ่มประชากรทีละขั้น: ติดตามประชากรผ่านเวลา
- การวิเคราะห์ผลกระทบด้านงบประมาณในทางปฏิบัติ: นับคนให้ชัดก่อนคำนวณเงิน
- ICER และผลประโยชน์สุทธิในรูปตัวเงินเมื่อมีหลายทางเลือก: หาขอบเขตทางเลือกที่มีประสิทธิภาพ
- การวิเคราะห์ความไวแบบอาศัยความน่าจะเป็น: จากการแจกแจงพารามิเตอร์สู่ CEAC
- การประเมินความสมเหตุสมผลและตรวจสอบแบบจำลอง: ทดสอบคำถาม สมการ และโค้ด (บทความนี้)
- มูลค่าคาดหมายของข้อมูลสมบูรณ์: หลักฐานที่ดีขึ้นอาจมีมูลค่าเพียงใด
ประเด็นสำคัญ
- ถามสองคำถามก่อนใช้ตัวเลขจากแบบจำลอง: สร้างถูกหรือไม่ (verification) และใช่แบบจำลองที่เหมาะกับผู้ป่วยกลุ่มนี้หรือไม่ (validation)
- ตรวจสอบด้วยคำตอบที่รู้ล่วงหน้า: ทุกแถวรวมได้ 1 คน 1,000 คนยังเป็น 1,000 คน และการคำนวณทีละปีด้วยมือได้ 640, 247.5 และ 112.5 เท่ากับเมทริกซ์
- เมื่อให้น้ำหนัก 1 แก่สถานะที่มีชีวิต QALY ต้องเท่ากับ 3,655.875 ปีชีวิต ถ้าปรับเรื่องเวลาเพียงฝั่งเดียว การตรวจจะไม่ผ่านโดยต่างกัน 250.231
- ตารางที่เติมตามคอลัมน์รันได้โดยไม่มีข้อความแจ้งข้อผิดพลาด แต่ทำให้กลุ่มคนหดจาก 1,000 เหลือ 409.6 คน มีเพียงการตรวจที่จับได้
- การประเมินความสมเหตุสมผลต้องอาศัยแพทย์ ที่มาของข้อมูล และข้อมูลภายนอก ให้เปิดค้างไว้ในบันทึกจนกว่าจะเทียบผู้เสียชีวิตที่ทำนายไว้ 253.2 คน
อ่านต่อในวิกิ: ต้นไม้การตัดสินใจ แบบจำลองมาร์คอฟ และการวิเคราะห์ความไวเชิงความน่าจะเป็น: แบบจำลองแปลงหลักฐานเป็นการตัดสินใจได้อย่างไร · พ้นจากพีระมิดหลักฐาน: ระดับความเชื่อมั่นตามแนวทาง GRADE และการเลือกข้อมูลประสิทธิผลเข้าสู่แบบจำลองทางเศรษฐศาสตร์