Bài 2: Mô phỏng vi mô bằng vòng lặp trong R cơ bản

Viết từng bước một mô phỏng vi mô có trí nhớ, kiểm tra nó và đọc lịch sử sống của từng người

Vì sao bạn nên quan tâm?

Một người bệnh 41 tuổi lĩnh TLD 3 tháng một lần tại phòng khám ngoại trú. Lần xét nghiệm định kỳ cho tải lượng trên 1,000 bản sao/mL. Theo hướng dẫn quốc gia, chị được tư vấn tăng cường tuân thủ tối đa 3 buổi, xét nghiệm lại sau 3 tháng, và nếu vẫn cao thì chuyển sang AZT + 3TC + LPV/r (Bộ Y tế 2021). Thuốc bậc hai có giá tham chiếu khoảng 6,982,000 VNĐ mỗi năm, gấp 8.5 lần TLD; phần chênh lệch bằng chừng 26 ngày lương tối thiểu vùng I mỗi năm, kéo dài suốt đời (The Global Fund 2026; Chính phủ 2025). Mỗi bước trên phụ thuộc vào lịch sử riêng của chị: đã được tư vấn chưa, còn mấy tháng nữa mới xét nghiệm lại, virus đã kháng thuốc hay chưa. Bài này giúp bạn viết đúng con đường đó thành mã R và kiểm tra được nó, trước khi dùng nó để quyết định lịch xét nghiệm.

Mục tiêu học tập

Sau bài này, bạn có thể:

  1. Viết thuật toán mô phỏng vi mô theo từng người: tạo thuộc tính, lặp qua các chu kỳ, dùng số ngẫu nhiên để quyết định biến cố, cộng dồn chi phí và năm sống điều chỉnh theo chất lượng (QALY).
  2. Mã hóa chuyển trạng thái phụ thuộc lịch sử bằng các biến “trí nhớ” của từng cá thể.
  3. Dùng một số ngẫu nhiên cho các nguy cơ cạnh tranh (competing risks: tử vong, đổi trạng thái virus, kháng thuốc), và dùng chung số ngẫu nhiên khi so sánh hai chiến lược.
  4. Xác minh mô hình bằng các kiểm tra có thể thất bại: bước chuyển hợp lệ, đếm tháng thất bại theo hai cách, quan hệ bao hàm giữa các biến cố.
  5. Đọc lịch sử sống của từng cá thể và vết mô phỏng vi mô.

Ý chính

  • Mỗi cá thể có thuộc tính cố định (tuổi khi vào, mức tuân thủ) và biến trí nhớ thay đổi theo thời gian (cờ kháng thuốc, số tháng thất bại virus học tích lũy, đồng hồ tư vấn, lịch xét nghiệm).
  • Mỗi chu kỳ có ba bước theo thứ tự cố định: một biến cố tối đa, cộng giá trị, theo dõi và xử trí. Đổi thứ tự là đổi mô hình.
  • Tử vong, đổi trạng thái virus và kháng thuốc là nguy cơ cạnh tranh (competing risks): đổi tỷ suất thành xác suất bằng phân rã tỷ suất thoát (M02 Bài 2), rồi dùng một số ngẫu nhiên \(U\) chia đoạn \([0, 1]\).
  • Dùng chung số ngẫu nhiên (common random numbers): cùng quần thể, cùng ma trận u[người, chu kỳ] rút sẵn cho mọi chiến lược. Chênh lệch khi đó phản ánh chiến lược, không phản ánh may rủi.
  • Kiểm tra tốt là kiểm tra có thể thất bại; một kiểm tra luôn đúng theo cách tính (hàng của vết cộng bằng \(N\)) không chứng minh gì về mô hình.

Nội dung

Thuật toán

Mô phỏng vi mô dùng hai vòng lặp lồng nhau: ngoài là từng người, trong là từng chu kỳ tháng. Ở mỗi chu kỳ, người đang xét đi qua ba bước.

flowchart TD
  A["Tạo quần thể: tuổi, mức tuân thủ"] --> B["Người i: đặt trí nhớ về 0<br/>trạng thái S1"]
  B --> C{"Chu kỳ t:<br/>còn sống?"}
  C -->|Có| D["1. Một biến cố tối đa, một số ngẫu nhiên U:<br/>chết, đổi trạng thái virus, hoặc kháng thuốc"]
  D --> F["2. Cộng chi phí, QALY, năm sống<br/>chiết khấu"]
  F --> G["3. Theo dõi: xét nghiệm, tư vấn,<br/>xét nghiệm lại, chuyển bậc 2"]
  G --> H{"t < số chu kỳ?"}
  H -->|Có| C
  C -->|Không| I["Lưu kết quả người i"]
  H -->|Không| I
  I --> J{"Còn người?"}
  J -->|Có| B
  J -->|Không| K["Lấy trung bình trên N người"]

flowchart TD
  A["Tạo quần thể: tuổi, mức tuân thủ"] --> B["Người i: đặt trí nhớ về 0<br/>trạng thái S1"]
  B --> C{"Chu kỳ t:<br/>còn sống?"}
  C -->|Có| D["1. Một biến cố tối đa, một số ngẫu nhiên U:<br/>chết, đổi trạng thái virus, hoặc kháng thuốc"]
  D --> F["2. Cộng chi phí, QALY, năm sống<br/>chiết khấu"]
  F --> G["3. Theo dõi: xét nghiệm, tư vấn,<br/>xét nghiệm lại, chuyển bậc 2"]
  G --> H{"t < số chu kỳ?"}
  H -->|Có| C
  C -->|Không| I["Lưu kết quả người i"]
  H -->|Không| I
  I --> J{"Còn người?"}
  J -->|Có| B
  J -->|Không| K["Lấy trung bình trên N người"]

Thuộc tính và trí nhớ

Tuổi khi vào mô hình theo phân phối chuẩn với trung bình 38.2 tuổi (Dat và c.s. 2018) và độ lệch chuẩn 7.0 tuổi, xấp xỉ mức 6.7 suy ra từ khoảng tứ phân vị 36–45 tuổi của khảo sát toàn quốc 2017–2018 (Dat và c.s. 2022) và cùng bậc với 35.4 ± 7.0 tuổi ở một mẫu khác (Tran, Ohinmaa, và Nguyen 2012), cắt trong khoảng 18–70 tuổi. Mức tuân thủ \(a_i\) theo phân phối Beta (giá trị minh họa). Hai thuộc tính này cố định suốt đời mỗi người. Bên cạnh đó, mỗi người mang năm biến trí nhớ: trạng thái hiện tại, cờ kháng thuốc \(R_i \in \{0, 1\}\), số tháng thất bại virus học tích lũy \(V_i\), số tháng tư vấn còn lại, và tháng xét nghiệm định kỳ kế tiếp.

Xác suất chuyển trong một chu kỳ

Mọi tỷ suất trong bảng tham số tính theo năm và được chia cho 12. Với người \(i\) ở tháng \(t\):

  • Tử vong: \(\mu_i(t) = \mu_{\text{nền}}(\text{tuổi}) + \mu_{\text{HIV}} \cdot (1 + \beta V_i / 12)\), trong đó \(\mu_{\text{nền}}\) lấy theo nhóm tuổi từ bảng sống Việt Nam của WHO (World Health Organization 2026); \(\mu_{\text{HIV}}\) bằng 0.004 khi ức chế virus và 0.04 khi thất bại virus học (mỗi năm), còn \(\beta =\) 0.5 cho mỗi năm thất bại tích lũy (giá trị minh họa). Đây là trí nhớ: hai người cùng ở S1 có nguy cơ tử vong khác nhau nếu quá khứ khác nhau.
  • Bật lại tải lượng virus (S1 → F1, S2 → F2): \(r(a_i)\) như Bài 1, với \(r_0 =\) 0.009/năm trên bậc một (chọn theo đoàn hệ miền Bắc, Bài 1) và 0.03/năm trên bậc hai (giá trị minh họa).
  • Tái ức chế virus (F1 → S1): 6.5/năm nhân với \(a_i\), nhân thêm 2 lần trong thời gian tư vấn tăng cường (hệ số này là giá trị minh họa); bằng 0 nếu đã kháng thuốc. Tỷ suất 6.5/năm được chọn để phần lớn người có xét nghiệm lại trên bậc một đã tái ức chế, như ở người dùng TLD trong một nghiên cứu lớn tại Zambia, nơi không ghi nhận được việc tư vấn tăng cường tuân thủ (Sikombe và c.s. 2025); Bài 3 kiểm tra điều này. Trên bậc hai (F2 → S2): 6/năm nhân với \(a_i\) (giá trị minh họa).
  • Kháng thuốc mắc phải với phác đồ bậc một (ở thời TLD là kháng dolutegravir): chỉ khi đang ở F1 và \(R_i = 0\), với tỷ suất \(\rho =\) 0.12/năm, chọn để tỷ lệ kháng thuốc trong nhóm thất bại virus học gần mức các khảo sát báo cáo cho WHO (Bài 3) (World Health Organization 2024). Người đó ở lại F1 nhưng \(R_i\) chuyển thành 1.

Ở F1 chưa kháng thuốc, ba biến cố loại trừ nhau cùng cạnh tranh: tử vong (\(\mu\)), tái ức chế (\(r\)) và kháng thuốc (\(\rho\)). Kháng thuốc kết thúc khả năng tái ức chế; tái ức chế kết thúc giai đoạn thất bại virus học; ta không biết biến cố nào đến trước trong tháng. Với tổng tỷ suất thoát \(R = \mu + r + \rho\) (mỗi tháng), xác suất mỗi biến cố bằng tỷ suất của nó chia \(R\), nhân \((1 - e^{-R})\). Ta rút một số \(U \sim \text{U}(0, 1)\) và chia \([0, 1]\) thành các đoạn: \(U < p_{\text{chết}}\) thì tử vong; tiếp theo \(p_{\text{đổi}}\) là đổi trạng thái virus; tiếp theo \(p_{\text{kháng}}\) là kháng thuốc; phần còn lại là ở yên. Ở các trạng thái khác, một số hạng nào bằng 0 thì đoạn của nó biến mất.

Quy tắc theo dõi

Hướng dẫn quốc gia hiện hành quy định xét nghiệm tải lượng virus (viral load, VL) sau 6 và 12 tháng điều trị, rồi 12 tháng một lần. Tư vấn tăng cường tuân thủ bắt đầu ngay khi tải lượng trên 50 bản sao/mL, tối đa 3 buổi cách nhau một tháng; thất bại virus học được xác định khi tải lượng trên 1,000 bản sao/mL ở hai lần xét nghiệm cách nhau 3 tháng, và khi đó người bệnh chuyển sang phác đồ bậc hai (Bộ Y tế 2021). Mô hình rút gọn quy trình này: chỉ có một ngưỡng (F1 nghĩa là trên 1,000 bản sao/mL), xét nghiệm định kỳ mỗi \(k\) tháng (\(k\) = 12 theo hướng dẫn, hoặc 6 như một nhánh của thử nghiệm ngẫu nhiên ở Hà Nội (Pollack và c.s. 2019)). Phát hiện thất bại thì tư vấn 3 tháng rồi xét nghiệm lại; vẫn thất bại trên bậc một thì chuyển bậc hai, dù có kháng thuốc hay không. “Chuyển phác đồ không cần thiết” là nhãn riêng của mô hình cho người chuyển khi virus chưa kháng thuốc; ở thời dolutegravir đây là câu hỏi thực tế, vì phần lớn người thất bại virus học không kháng thuốc mà chỉ uống thuốc chưa đều.

Giá trị gắn với trạng thái

Cách tính theo quy ước của M02 Bài 3. Chi phí điều trị (thuốc ARV và khám) trả đầu tháng, nên tính đủ cho ai còn sống ở đầu chu kỳ. Chi phí chăm sóc thêm khi thất bại virus học, giá trị thỏa dụng và năm sống tích lũy dần trong tháng nên dùng quy tắc hình thang (trapezoid rule, trung bình giá trị ở đầu và cuối chu kỳ); chiết khấu tại thời điểm tương ứng. Chi phí xét nghiệm và tư vấn là chi phí sự kiện, tính vào tháng xảy ra. Mọi đơn giá lấy từ bảng giá dùng chung của khóa học, VNĐ năm 2025: thuốc TLD 68,251 VNĐ mỗi tháng (The Global Fund 2026); 4 lượt khám mỗi năm (lĩnh thuốc 3 tháng một lần), mỗi lượt 50,600 VNĐ; một xét nghiệm tải lượng 979,700 VNĐ; một đợt tư vấn bằng 3 lượt khám; ngày giường truyền nhiễm 327,100 VNĐ (Bộ Y tế 2024). Riêng số ngày giường thêm mỗi tháng khi thất bại virus học (1.5) là giá trị minh họa. Giá trị thỏa dụng khi ức chế virus là 0.80, trung bình EQ-5D-5L của 482 người điều trị ARV ở năm phòng khám miền Bắc năm 2017 (Tran và c.s. 2018); bài báo không nêu bộ giá trị và không tách theo tải lượng, nên đây là giá trị gần đúng. Mức giảm khi thất bại virus học, 0.02, là giả định, mượn từ chênh lệch EQ-5D giữa người nhiễm HIV chưa tiến triển và giai đoạn AIDS trong một khảo sát năm 2008 (Tran và c.s. 2011).

Dùng chung số ngẫu nhiên

Để so sánh hai chiến lược, ta cho cùng một quần thể đi qua mỗi chiến lược với cùng một dãy số ngẫu nhiên (Stout và Goldie 2008). Mọi số ngẫu nhiên được rút trước vào ma trận u[i, t], kể cả cho những tháng sau khi người đó đã chết, nên số của người \(i\) ở tháng \(t\) không phụ thuộc vào việc các tháng trước đã rút bao nhiêu số. Người nào có cùng lịch sử ở hai chiến lược cho tới tháng \(t\) sẽ gặp cùng sự kiện ở tháng \(t\); chênh lệch chỉ bắt đầu khi chiến lược thực sự làm thay đổi điều gì đó.

Thực hành

Phần đầu nạp hàm và tham số, đặt số người và hai seed: một cho việc tạo quần thể, một cho các sự kiện. Số người nhỏ có chủ ý vì vòng lặp theo từng người chạy chậm.

if (!isTRUE(l10n_info()[["UTF-8"]])) invisible(Sys.setlocale("LC_CTYPE", "en_US.UTF-8"))
source("../_shared/R/helpers.R")
source("../_shared/R/vn_data.R")   # course-wide values: prices, threshold, life table
source("code/model.R")
library(ggplot2)
p <- load_hiv_params()             # params/hiv.csv + the shared Vietnamese inputs
N_PEOPLE <- 500      # small on purpose: the loop engine is slow (Lesson 3 makes it fast)
SEED_POP <- 101      # seed for drawing the population
SEED_SIM <- 202      # seed for the random numbers that drive events
ADH_LOW <- 0.80      # mean adherence of the poorer-adherence population in Exercise 1

make_population() tạo bảng thuộc tính, mỗi hàng một người. Hãy kiểm tra tuổi nằm trong khoảng cho phép và mức tuân thủ nằm giữa 0 và 1.

# Beta shape parameters from a mean and a concentration (kappa = shape1 + shape2).
adherence_beta <- function(p) {
  c(shape1 = p$adh_mean * p$adh_kappa, shape2 = (1 - p$adh_mean) * p$adh_kappa)
}

# Baseline attributes for n people: age at entry and a fixed personal adherence level.
make_population <- function(p, n, seed) {
  set.seed(seed)
  age0 <- stats::rnorm(n, p$age_mean, p$age_sd)
  age0 <- pmin(pmax(age0, p$age_min), p$age_max)
  ab <- adherence_beta(p)
  adh <- stats::rbeta(n, ab[["shape1"]], ab[["shape2"]])
  data.frame(id = seq_len(n), age0 = age0, adh = adh)
}
pop <- make_population(p, N_PEOPLE, seed = SEED_POP)
head(pop, 4)
  id     age0       adh
1  1 35.91774 0.9827810
2  2 42.06723 0.9573850
3  3 33.47539 0.9519000
4  4 39.70052 0.9031414
round(rbind(age = summary(pop$age0), adherence = summary(pop$adh)), 3)
            Min. 1st Qu. Median   Mean 3rd Qu.   Max.
age       18.000  33.125 37.652 37.765  42.226 56.307
adherence  0.538   0.866  0.924  0.906   0.964  1.000
stopifnot(nrow(pop) == N_PEOPLE, all(pop$adh > 0 & pop$adh < 1),
          all(pop$age0 >= p$age_min & pop$age0 <= p$age_max))

Hàm transition_probs() là trái tim của mô hình. Nó nhận trạng thái và trí nhớ của một hay nhiều người rồi trả về ba xác suất trong một tháng: tử vong, đổi trạng thái virus, kháng thuốc. Đọc kỹ dòng mu_hiv: số tháng thất bại tích lũy cum_vir đi thẳng vào nguy cơ tử vong. Ba xác suất dùng chung một tổng tỷ suất total.

# Annual background death rate: the Vietnamese life table (step function over age groups,
# the same rule as vn_mx() in ../_shared/R/vn_data.R, but without re-reading the file).
bg_mortality <- function(age, p) p$lt_mx[findInterval(age, p$lt_age)]

# Annual rate of viral rebound: multiplied by hr_adh10 for every 10 points of lost adherence.
rebound_rate <- function(adh, base, p) base * p$hr_adh10^(10 * (1 - adh))

# Monthly probabilities of three mutually exclusive events, for one person or for a whole
# vector of people: death, change of viral state, and acquired resistance (only in state F1
# and only if not yet resistant). Inputs: current state (1-5), adherence, age, resistance
# flag (0/1), months lived with unsuppressed virus (cum_vir), months left in enhanced
# adherence counselling (eac). The three events are competing risks: they share one total
# exit rate, and each gets its share of the probability of leaving the state (M02 Lesson 2).
transition_probs <- function(state, adh, age, res, cum_vir, eac, p) {
  viremic <- state == 2L | state == 4L
  mu_hiv <- ifelse(viremic, p$mu_vir, p$mu_supp) * (1 + p$vir_slope * cum_vir / 12)
  death <- (bg_mortality(age, p) + mu_hiv) / 12
  boost <- ifelse(eac > 0, p$hr_eac, 1)
  move <- ifelse(state == 1L, rebound_rate(adh, p$r_reb1, p),
          ifelse(state == 2L, (1 - res) * p$r_resup1 * adh * boost,
          ifelse(state == 3L, rebound_rate(adh, p$r_reb2, p),
                              p$r_resup2 * adh * boost))) / 12
  adr <- ifelse(state == 2L & res == 0, p$r_adr, 0) / 12
  total <- death + move + adr
  p_exit <- rate_to_prob(total)
  p_die <- death / total * p_exit
  p_move <- move / total * p_exit
  p_adr <- adr / total * p_exit
  dead <- state == 5L
  p_die[dead] <- 0
  p_move[dead] <- 0
  p_adr[dead] <- 0
  list(die = p_die, move = p_move, adr = p_adr)
}

Ba người cùng ở S1, cùng tuổi. A và C có cùng mức tuân thủ, nhưng C đã từng sống một thời gian với virus không ức chế. Điểm kiểm tra xác nhận C có nguy cơ tử vong cao hơn A và B có nguy cơ bật lại cao hơn A.

# Three people in the SAME state (first line, suppressed) at age 40.
# A and C have the same adherence; C has lived 24 months with unsuppressed virus before.
demo <- data.frame(person = c("A", "B", "C"), adh = c(0.95, 0.70, 0.95), cum_vir = c(0, 0, 24))
pr <- transition_probs(state = rep(1L, 3), adh = demo$adh, age = 40, res = 0,
                       cum_vir = demo$cum_vir, eac = 0, p = p)
demo$p_die_month <- pr$die
demo$p_rebound_month <- pr$move
demo[, 4:5] <- round(demo[, 4:5], 5)
demo
  person  adh cum_vir p_die_month p_rebound_month
1      A 0.95       0     0.00055         0.00106
2      B 0.70       0     0.00055         0.00598
3      C 0.95      24     0.00088         0.00106
stopifnot(demo$p_die_month[3] > demo$p_die_month[1],          # memory raises mortality
          demo$p_rebound_month[2] > demo$p_rebound_month[1])  # low adherence raises rebound

C mang 24 tháng thất bại virus học tích lũy. Xác suất tử vong mỗi tháng của C là 0.00088, so với 0.00055 của A. Trong mô hình đoàn hệ, hai người này nằm chung một ô và không thể phân biệt.

Giá trị của từng trạng thái nằm trong state_values(), gồm chi phí điều trị trả đầu tháng, chi phí thêm khi thất bại virus học, thỏa dụng và năm sống.

Xem hàm state_values()
# Value of being in each state for one month (cost, VND) or one year (utility, alive).
# Same two conventions as M02 Lesson 3: treatment cost (ARV drugs + clinic visits) is paid
# at the start of the month, so it is charged in full to anyone alive in that state at the
# start; the extra care while viraemic, the utility and the life-years accrue over the
# month and use the trapezoid rule (mean of the values at the start and end of the cycle).
#   first line: TLD price / 12; second line: USD price per year x exchange rate / 12
#   visits: outpatient visit price x visits per year / 12 (90-day refills when stable)
#   extra care while viraemic: bed-days per month x bed-day price
state_values <- function(p) {
  c_visit <- p$c_visit_unit * p$visits_per_year / 12
  c_art2 <- p$art2_usd_year * p$fx / 12
  c_vir <- p$vir_beddays_month * p$c_bedday
  u_vir <- p$u_supp - p$u_vir_dec
  list(
    cost_tx = c(p$c_art1 + c_visit, p$c_art1 + c_visit, c_art2 + c_visit, c_art2 + c_visit, 0),
    cost_vir = c(0, c_vir, 0, c_vir, 0),
    utility = c(p$u_supp, u_vir, p$u_supp, u_vir, 0),
    alive = c(1, 1, 1, 1, 0)
  )
}

# Shared set-up for every engine. One episode of enhanced adherence counselling (EAC) costs
# eac_sessions outpatient visits; it is charged when a high viral load is detected.
sim_setup <- function(p, strategy) {
  n_cycles <- p$horizon_years * 12
  list(
    n_cycles = n_cycles,
    intervals = strategy_intervals(p, strategy),
    v = state_values(p),
    c_eac = p$eac_sessions * p$c_visit_unit,
    disc_start = discount_weights((seq_len(n_cycles) - 1) / 12, p$disc_rate),
    disc_mid = discount_weights((seq_len(n_cycles) - 0.5) / 12, p$disc_rate),
    disc_end = discount_weights(seq_len(n_cycles) / 12, p$disc_rate)
  )
}

Đây là toàn bộ bộ máy vòng lặp. Vòng ngoài duyệt từng người, vòng trong duyệt từng tháng và dừng khi người đó tử vong. Ba bước trong vòng trong khớp với sơ đồ ở trên.

Xem hàm run_microsim_loop()
# Engine 1: one person at a time, one cycle at a time (easy to read, slow).
# u is a matrix [person, cycle] of uniform random numbers drawn in advance: u[i, t] decides
# which event (if any) happens to person i in cycle t. Cycle t uses column t of u.
run_microsim_loop <- function(p, pop, strategy, seed) {
  s <- sim_setup(p, strategy)
  n <- nrow(pop)
  set.seed(seed)
  u <- matrix(stats::runif(n * s$n_cycles), n, s$n_cycles)

  states <- matrix(5L, n, s$n_cycles + 1)       # state history, filled with "dead"
  f1_res <- integer(s$n_cycles + 1)             # people in F1 with resistance, by month
  out <- data.frame(cost = numeric(n), qaly = numeric(n), ly = numeric(n),
                    ly_undisc = numeric(n), months_vir = numeric(n), res = logical(n),
                    switched = logical(n), unnecessary = logical(n),
                    n_vl = numeric(n), n_eac = numeric(n), n_rep1 = numeric(n))

  for (i in seq_len(n)) {
    # Attributes and memory of person i
    state <- 1L; res <- 0L; cum_vir <- 0; eac <- 0; ever_flag <- FALSE
    next_test <- s$intervals[["first"]]
    cost <- 0; qaly <- 0; ly <- 0; ly_undisc <- 0; n_vl <- 0; n_eac <- 0; n_rep1 <- 0
    switched <- FALSE; unnecessary <- FALSE
    states[i, 1] <- state
    for (t in seq_len(s$n_cycles)) {
      if (state == 5L) break
      # 1. One event at most: death, change of viral state, or acquired resistance
      pr <- transition_probs(state, pop$adh[i], pop$age0[i] + (t - 1) / 12, res, cum_vir, eac, p)
      new_state <- state
      if (u[i, t] < pr$die) {
        new_state <- 5L
      } else if (u[i, t] < pr$die + pr$move) {
        new_state <- NEXT_VIRAL_STATE[state]
      } else if (u[i, t] < pr$die + pr$move + pr$adr) {
        res <- 1L                                # stays in F1, now resistant
      }
      # 2. Rewards: treatment cost at the start of the cycle; the rest by the trapezoid rule
      cost <- cost + s$v$cost_tx[state] * s$disc_start[t] +
        (s$v$cost_vir[state] + s$v$cost_vir[new_state]) / 2 * s$disc_mid[t]
      qaly <- qaly + (s$v$utility[state] + s$v$utility[new_state]) / 2 / 12 * s$disc_mid[t]
      ly_t <- (s$v$alive[state] + s$v$alive[new_state]) / 2 / 12
      ly <- ly + ly_t * s$disc_mid[t]
      ly_undisc <- ly_undisc + ly_t
      cum_vir <- cum_vir + (state == 2L || state == 4L)
      state <- new_state
      # 3. Monitoring at the end of the cycle
      if (state != 5L) {
        tested <- FALSE; detected <- FALSE
        if (eac > 0) {
          eac <- eac - 1
          if (eac == 0) {                       # repeat test after counselling
            tested <- TRUE
            if (state == 1L || state == 2L) n_rep1 <- n_rep1 + 1
            if (state == 2L) {                  # still failing on first line: switch
              state <- 4L
              switched <- TRUE
              if (res == 0) unnecessary <- TRUE
            }
            next_test <- t + s$intervals[["after"]]
          }
        } else if (t >= next_test) {            # routine test
          tested <- TRUE
          if (state == 2L || state == 4L) {
            detected <- TRUE
            eac <- p$eac_months
            ever_flag <- TRUE
          } else {
            next_test <- t + if (ever_flag) s$intervals[["after"]] else s$intervals[["first"]]
          }
        }
        n_vl <- n_vl + tested
        n_eac <- n_eac + detected
        cost <- cost + (tested * p$c_vl + detected * s$c_eac) * s$disc_end[t]
      }
      states[i, t + 1] <- state
      if (state == 2L && res == 1L) f1_res[t + 1] <- f1_res[t + 1] + 1L
    }
    out[i, ] <- list(cost, qaly, ly, ly_undisc, cum_vir, res == 1, switched, unnecessary,
                     n_vl, n_eac, n_rep1)
  }

  trace <- t(apply(states, 2, tabulate, nbins = 5))
  storage.mode(trace) <- "integer"
  dimnames(trace) <- list(0:s$n_cycles, STATES)
  list(indiv = out, trace = trace, f1_res = f1_res, states = states, strategy = strategy)
}

Ta chạy chiến lược xét nghiệm 12 tháng/lần cho 500 người và đo thời gian. Kết quả gồm bảng kết cục từng người, vết mô phỏng và ma trận lịch sử trạng thái (người × chu kỳ).

time_loop <- system.time(
  sim12 <- run_microsim_loop(p, pop, "vl12", seed = SEED_SIM)
)[["elapsed"]]
time_loop
[1] 2.186
dim(sim12$states)               # people x (cycles + 1)
[1] 500 841
head(sim12$indiv, 3)
      cost      qaly        ly ly_undisc months_vir   res switched unnecessary
1 48257410 18.712168 23.395709  39.79167          3 FALSE    FALSE       FALSE
2 12708709  5.139763  6.424703   7.12500          0 FALSE    FALSE       FALSE
3 21574348  8.426061 10.536896  12.62500          3 FALSE    FALSE       FALSE
  n_vl n_eac n_rep1
1   40     1      1
2    7     0      0
3   12     0      0

Vòng lặp mất 2.2 giây cho 500 người. Trước khi đọc kết quả, ta xác minh mô hình. Kiểm tra 0 chỉ là phép cộng số học nên chỉ bắt được lỗi ở chính phép cộng. Các kiểm tra 1 đến 5 có thể thất bại nếu mã sai: mọi bước chuyển giữa hai tháng liên tiếp phải là mũi tên của sơ đồ mô hình; số tháng thất bại virus học đếm theo hai cách độc lập phải bằng nhau; chỉ người đã chuyển phác đồ mới ở bậc hai, và mỗi lần chuyển phải đi sau một lần xét nghiệm lại trên bậc một; các quan hệ bao hàm và miền giá trị phải đúng.

tr <- sim12$trace
st <- sim12$states
ind <- sim12$indiv
n_cycles <- ncol(st) - 1
# (0) Arithmetic only: the trace counts a complete matrix, so each row sums to N by construction.
#     This cannot fail unless the code that sums is wrong; it is not a check of the model.
check_trace(tr / N_PEOPLE)
# (1) Every move between consecutive months must be an arrow of the model diagram.
#     S1 -> F2 is possible in one month: rebound in the last month of counselling, then switch.
from <- st[, 1:n_cycles]
to <- st[, 2:(n_cycles + 1)]
moves_seen <- table(from = factor(from, levels = 1:5, labels = STATES),
                    to = factor(to, levels = 1:5, labels = STATES))
allowed <- matrix(0, 5, 5, dimnames = list(STATES, STATES))
allowed[cbind(c("S1", "S1", "S1", "S1", "F1", "F1", "F1", "F1", "S2", "S2", "S2",
                "F2", "F2", "F2", "D"),
              c("S1", "F1", "F2", "D", "F1", "S1", "F2", "D", "S2", "F2", "D",
                "F2", "S2", "D", "D"))] <- 1
moves_seen
    to
from     S1     F1     S2     F2      D
  S1 207859    359      0      0    476
  F1    336    583      0     17      6
  S2      0      0   3647     50     17
  F2      0      0     67    121      0
  D       0      0      0      0 206462
stopifnot(all(moves_seen[allowed == 0] == 0))
# (2) Months with unsuppressed virus, counted two ways: by the model's memory variable and
#     by counting cycles that started in F1 or F2 in the stored history
stopifnot(all(ind$months_vir == rowSums(from == 2L | from == 4L)))
# (3) Line 2 only after a switch; a switch needs a detection, two tests and a repeat test
#     on first line; there is never more than one repeat test per detection
on_line2 <- rowSums(st == 3L | st == 4L) > 0
stopifnot(identical(on_line2, ind$switched),
          all(ind$n_eac[ind$switched] >= 1), all(ind$n_vl[ind$switched] >= 2),
          all(ind$n_rep1 >= ind$switched), all(ind$n_rep1 <= ind$n_eac))
# (4) Logical inclusions: an "unnecessary" switch is a switch; resistance needs viraemia;
#     resistant people in F1 cannot outnumber people in F1
stopifnot(all(ind$unnecessary <= ind$switched), all(ind$months_vir[ind$res] > 0),
          all(sim12$f1_res <= tr[, "F1"]))
# (5) Value ranges
stopifnot(all(ind$cost >= 0), all(ind$qaly <= ind$ly + 1e-12))
alive_end <- 1 - tr[nrow(tr), "D"] / N_PEOPLE
alive_end
[1] 0.002

Mọi kiểm tra đều qua. Bảng đếm có 420,000 cặp người-tháng. Bước S1 → F2 trong một tháng là hợp lệ (bật lại đúng tháng cuối của đợt tư vấn rồi bị chuyển) nhưng hiếm: với 500 người ở đây nó không xảy ra lần nào. Cuối khung thời gian, 0.2% số người còn sống, nghĩa là khung 70 năm gần như trọn đời. Kiểm tra mạnh nhất, quy mô phỏng vi mô về mô hình đoàn hệ, cần quần thể lớn nên để dành cho Bài 3.

Hình lịch sử sống cho thấy điều mà mô hình đoàn hệ không thể hiện: mỗi người có một con đường riêng. Đoạn mã chọn người ức chế virus suốt, người chuyển bậc hai vì kháng thuốc, người chuyển bậc hai dù chưa kháng thuốc, và người thất bại rồi tự tái ức chế (nếu quần thể nhỏ không có ai thuộc một nhóm, nhóm đó bị bỏ qua).

# Life histories of six people: first 20 years
ind <- sim12$indiv
pick <- c(which(!ind$switched & ind$months_vir == 0)[1:2],
          which(ind$switched & !ind$unnecessary)[1:2],
          which(ind$unnecessary)[1],
          which(!ind$switched & ind$months_vir > 0)[1])
pick <- pick[!is.na(pick)]
n_pick <- length(pick)
months <- 0:240
hist_df <- data.frame(
  person = factor(rep(paste("Người", pick), each = length(months)),
                  levels = rev(paste("Người", pick))),
  month = rep(months, times = length(pick)),
  state = factor(STATES[as.vector(t(sim12$states[pick, months + 1]))], levels = STATES,
                 labels = STATE_LABELS)
)
fig_hist <- ggplot(hist_df, aes(month / 12, person, fill = state)) +
  geom_tile(height = 0.8) +
  scale_fill_manual(values = pal_course[c(3, 2, 5, 4, 8)], name = NULL, drop = FALSE) +
  labs(x = "Năm", y = NULL, title = paste0("Lịch sử sống của ", n_pick, " cá thể mô phỏng (xét nghiệm ", p$vl_soc, " tháng/lần)")) +
  theme_course() +
  guides(fill = guide_legend(nrow = 2))
save_fig(fig_hist, "l02-histories.png", width = 8, height = 3.8)

Lịch sử sống của 6 cá thể mô phỏng trong 20 năm đầu, chiến lược xét nghiệm 12 tháng/lần.

Cộng dồn các lịch sử sống theo từng tháng cho ta vết mô phỏng vi mô, tương đương vết đoàn hệ ở M02. Bảng in ra tỷ lệ ở mỗi trạng thái tại một số tháng; mỗi hàng phải cộng lại bằng 1.

# Microsimulation trace: share of the simulated people in each state, by month
share <- tr / N_PEOPLE
month_show <- 120                       # the month used in the text below the table
round(share[c("0", "12", "60", as.character(month_show), "240"), ], 3)
       S1    F1    S2    F2     D
0   1.000 0.000 0.000 0.000 0.000
12  0.988 0.002 0.000 0.000 0.010
60  0.958 0.006 0.002 0.000 0.034
120 0.920 0.004 0.010 0.002 0.064
240 0.812 0.008 0.014 0.000 0.166
trace_df <- data.frame(
  year = rep(as.numeric(rownames(share)) / 12, times = 5),
  state = factor(rep(STATE_LABELS, each = nrow(share)), levels = STATE_LABELS),
  share = as.vector(share)
)
fig_trace <- ggplot(subset(trace_df, year <= 40), aes(year, share, fill = state)) +
  geom_area() +
  scale_fill_manual(values = pal_course[c(3, 2, 5, 4, 8)], name = NULL) +
  scale_y_continuous(labels = function(x) paste0(100 * x, "%")) +
  labs(x = "Năm", y = "Tỷ lệ cá thể", title = paste0("Vết mô phỏng vi mô: cộng dồn ", N_PEOPLE, " lịch sử sống")) +
  theme_course() +
  guides(fill = guide_legend(nrow = 2))
save_fig(fig_trace, "l02-trace.png", width = 7, height = 4.2)

Vết mô phỏng vi mô, chiến lược xét nghiệm 12 tháng/lần.

Sau 10 năm, 1.2% số người đang dùng phác đồ bậc hai. Đường gồ ghề của vết phản ánh nhiễu ngẫu nhiên với 500 người.

Cuối cùng, ta chạy chiến lược 6 tháng/lần trên cùng quần thể và cùng seed, rồi tính chênh lệch theo từng cặp người.

# Same people, same random numbers, two monitoring strategies (common random numbers)
sim6 <- run_microsim_loop(p, pop, "vl6", seed = SEED_SIM)
res_l02 <- rbind(vl12 = summarise_sim(sim12), vl6 = summarise_sim(sim6))
res_l02_show <- data.frame(
  cost_million = res_l02[, "cost"] / 1e6, qaly = res_l02[, "qaly"], ly = res_l02[, "ly"],
  months_vir = res_l02[, "months_vir"], p_switch = res_l02[, "p_switch"],
  p_unnecessary = res_l02[, "p_unnecessary"], n_vl = res_l02[, "n_vl"],
  row.names = rownames(res_l02))
round(res_l02_show, 3)
     cost_million   qaly     ly months_vir p_switch p_unnecessary   n_vl
vl12       43.516 16.558 20.701      2.260    0.034         0.018 35.192
vl6        64.072 16.547 20.686      1.944    0.040         0.024 70.656
diff_l02 <- paired_diff(list(vl12 = sim12, vl6 = sim6), wtp = p$wtp)
round(unlist(diff_l02[c("d_cost", "se_cost", "d_qaly", "se_qaly", "d_nmb", "se_nmb")]), 4)
       d_cost       se_cost        d_qaly       se_qaly         d_nmb 
 2.055641e+07  4.861867e+05 -1.120000e-02  8.100000e-03 -2.196216e+07 
       se_nmb 
 1.191154e+06 
# Does the 95% Monte Carlo interval (+-1.96 SE) of each paired difference contain 0?
has0 <- function(est, se) abs(est) < 1.96 * se
ci0_l02 <- c(cost = has0(diff_l02$d_cost, diff_l02$se_cost),
             qaly = has0(diff_l02$d_qaly, diff_l02$se_qaly),
             nmb = has0(diff_l02$d_nmb, diff_l02$se_nmb))
ci0_l02
 cost  qaly   nmb 
FALSE  TRUE FALSE 
# How much of the extra cost is testing? Cost is linear in the test price: rerun both
# strategies with free tests (same people, same random numbers) and compare the cost difference.
p_free <- p
p_free$c_vl <- 0
d_free <- paired_diff(list(vl12 = run_microsim_loop(p_free, pop, "vl12", seed = SEED_SIM),
                           vl6 = run_microsim_loop(p_free, pop, "vl6", seed = SEED_SIM)), wtp = p$wtp)
share_test_cost <- 1 - d_free$d_cost / diff_l02$d_cost
round(c(share_of_extra_cost_that_is_testing = share_test_cost), 3)
share_of_extra_cost_that_is_testing 
                              0.986 
# Statements used in the lesson text (update the text if a check fails)
stopifnot(res_l02["vl6", "months_vir"] < res_l02["vl12", "months_vir"],
          res_l02["vl6", "n_vl"] > res_l02["vl12", "n_vl"],
          share_test_cost > 0.8,                              # the extra cost is mostly testing
          !ci0_l02[["cost"]], ci0_l02[["qaly"]],              # cost sign is clear, QALY sign is not
          abs(p$wtp * diff_l02$d_qaly) < abs(diff_l02$d_cost))  # the NMB difference is driven by cost

Xét nghiệm 6 tháng/lần làm số tháng thất bại virus học trung bình giảm từ 2.3 xuống 1.9 tháng, nhưng tăng số xét nghiệm suốt đời từ 35.2 lên 70.7 lần mỗi người. Hai loại chênh lệch cư xử rất khác nhau khi chỉ có 500 người. Chênh lệch chi phí là 20.56 triệu VNĐ mỗi người, sai số Monte Carlo (Monte Carlo error) chỉ 0.49 triệu VNĐ: khoảng 99% trong đó là tiền xét nghiệm (chạy lại với xét nghiệm miễn phí), ai còn sống cũng phải trả, nên dấu đã rõ. Chênh lệch năm sống điều chỉnh theo chất lượng (QALY) là -0.0112, sai số 0.0081; khoảng ±1.96 sai số chứa 0, và ước lượng thậm chí âm, tức xét nghiệm dày hơn có vẻ làm hại sức khỏe: đó là nhiễu, không phải tác dụng. Lợi ích sức khỏe của việc xét nghiệm dày hơn chỉ đến với số ít người có đợt thất bại virus học được phát hiện sớm hơn, nên 500 người là quá ít để thấy nó. Ở ngưỡng 1 × GDP bình quân đầu người, chênh lệch lợi ích ròng quy ra tiền (net monetary benefit, NMB) là -21.96 triệu VNĐ (sai số 1.19 triệu VNĐ), bị chi phí xét nghiệm chi phối. Bài 3 làm mô hình chạy nhanh để mô phỏng nhiều người hơn, và Bài 5 trả lời câu hỏi “bao nhiêu người là đủ” cho từng loại chênh lệch.

Mẹo thực hành
  • Viết hàm xác suất chuyển nhận được cả số lẻ lẫn vectơ, như transition_probs(). Vòng lặp, bản vectơ hóa và các kiểm tra đơn lẻ khi đó dùng chung một công thức.
  • Lưu ma trận lịch sử trạng thái khi phát triển mô hình với số người nhỏ, và đặt seed quần thể và seed sự kiện riêng, để đổi được từng thứ.
  • Vẽ lịch sử sống của vài người trước khi xem kết quả trung bình. Những lỗi logic như chuyển bậc hai mà không qua xét nghiệm lại lộ ra ngay trên hình.
Lỗi thường gặp
  • Rút số ngẫu nhiên “khi cần” trong vòng lặp, ví dụ chỉ rút khi người đó còn sống. Hai chiến lược khi đó lệch dãy số ngay sau sự kiện đầu tiên khác nhau, và lợi ích của việc dùng chung số ngẫu nhiên mất đi.
  • Dùng nhiều số ngẫu nhiên độc lập cho các nguy cơ cạnh tranh, hoặc xét một biến cố trước bằng số riêng rồi để nó chặn biến cố kia trong cùng tháng (ví dụ kháng thuốc xét trước và chặn tái ức chế). Như vậy hai nguy cơ cạnh tranh thành nối tiếp, xác suất lệch nhiều khi chu kỳ dài so với tỷ suất. Dùng một số \(U\) cho các sự kiện loại trừ nhau.
  • Cập nhật trí nhớ sai thời điểm, ví dụ cộng tháng thất bại sau khi đã đổi trạng thái. Ghi rõ thứ tự các bước trong chú thích mã.
  • Quên rằng người chết vẫn chiếm chỗ trong ma trận số ngẫu nhiên. Đó là chủ đích, không phải lãng phí.

Bài tập

  1. (Dễ) Tạo một quần thể có tuân thủ trung bình 0.8 thay vì 0.9. So sánh số tháng thất bại virus học, tỷ lệ kháng thuốc, tỷ lệ chuyển bậc hai và số năm sống với quần thể gốc.
  2. (Vừa) Chạy chiến lược bậc thang vl12_then6: xét nghiệm 12 tháng/lần, rút xuống 6 tháng/lần sau lần đầu phát hiện thất bại. Chiến lược này nằm ở đâu giữa hai chiến lược chính về chi phí, QALY và số xét nghiệm? Vì sao mô hình đoàn hệ khó mã hóa chiến lược này? (Đây không phải nhánh “xét nghiệm có mục tiêu” của thử nghiệm Hà Nội (Pollack và c.s. 2019), vốn chỉ xét nghiệm tải lượng khi có thất bại lâm sàng hoặc miễn dịch.)
  3. (Khó) Những ai bị chuyển phác đồ không cần thiết trong chiến lược 6 tháng/lần? So sánh mức tuân thủ của họ với những người còn lại và giải thích cơ chế.

Bài 1. Chỉ cần đổi một tham số và tạo lại quần thể với cùng seed:

# Exercise 1: a population with poorer adherence (mean 0.80)
p_low <- p
p_low$adh_mean <- ADH_LOW
pop_low <- make_population(p_low, N_PEOPLE, seed = SEED_POP)
sim_low <- run_microsim_loop(p_low, pop_low, "vl12", seed = SEED_SIM)
round(rbind(base = summarise_sim(sim12), adh80 = summarise_sim(sim_low))[
  , c("months_vir", "p_res", "p_switch", "ly")], 3)
      months_vir p_res p_switch     ly
base       2.260 0.016    0.034 20.701
adh80      6.284 0.032    0.084 20.618
stopifnot(summarise_sim(sim_low)[["ly"]] < summarise_sim(sim12)[["ly"]])

Tuân thủ kém hơn làm số tháng thất bại virus học tăng gấp khoảng 2.8 lần và số năm sống giảm. Đây là ảnh hưởng của không đồng nhất lên kết cục trung bình.

Bài 2. Chiến lược bậc thang cần nhớ “người này đã từng bị phát hiện thất bại chưa”. Trong mô phỏng vi mô đó chỉ là một biến ever_flag. Trong mô hình đoàn hệ, mọi trạng thái phải nhân đôi.

# Exercise 2: the step-up strategy (12-monthly, then 6-monthly after a first detected failure)
sim_tg <- run_microsim_loop(p, pop, "vl12_then6", seed = SEED_SIM)
round(rbind(vl12 = summarise_sim(sim12), vl12_then6 = summarise_sim(sim_tg),
            vl6 = summarise_sim(sim6))[, c("cost", "qaly", "n_vl", "p_switch")], 3)
               cost   qaly   n_vl p_switch
vl12       43515915 16.558 35.192    0.034
vl12_then6 45158380 16.552 39.080    0.034
vl6        64072330 16.547 70.656    0.040

Bài 3. So sánh mức tuân thủ của người bị chuyển không cần thiết với những người còn lại:

# Exercise 3: who is switched without resistance? Compare their adherence with the rest
unn <- sim6$indiv$unnecessary
round(c(n_unnecessary = sum(unn), n_switched = sum(sim6$indiv$switched),
        adh_unnecessary = mean(pop$adh[unn]), adh_others = mean(pop$adh[!unn])), 3)
  n_unnecessary      n_switched adh_unnecessary      adh_others 
         12.000          20.000           0.837           0.908 
stopifnot(sum(unn) >= 3,                                   # enough people to compare
          mean(pop$adh[unn]) < mean(pop$adh[!unn]),
          sum(sim6$indiv$unnecessary) > sum(sim12$indiv$unnecessary))

Người bị chuyển không cần thiết có tuân thủ trung bình 83.7%, thấp hơn mức 90.8% của những người còn lại. Virus của họ chưa kháng thuốc nhưng họ không tái ức chế kịp trong 3 tháng tư vấn. Với phác đồ có dolutegravir, kháng thuốc hiếm, nên trong chiến lược 6 tháng/lần có 12 trên 20 lần chuyển bậc hai là không cần thiết theo định nghĩa của mô hình. Xét nghiệm dày hơn phát hiện nhiều đợt thất bại thoáng qua hơn, nên cũng tạo ra nhiều lần chuyển không cần thiết hơn (12 so với 9 ở chiến lược 12 tháng/lần, cùng 500 người). Đây là lý do thực hành hiện nay nhấn mạnh tư vấn tuân thủ trước khi đổi phác đồ.

Tài liệu tham khảo

# Slide Lời giảng chính Hình/Code
1 Mục tiêu Một người bệnh ở phòng khám: xét nghiệm, tư vấn, xét nghiệm lại, chuyển bậc hai; từ sơ đồ trạng thái đến hàng trăm lịch sử sống Khung “Vì sao bạn nên quan tâm?”, sơ đồ thuật toán
2 Thuộc tính và trí nhớ Hai thuộc tính cố định, năm biến trí nhớ Chunk l02-population
3 Công thức chuyển trạng thái Tử vong có trí nhớ; bật lại theo tuân thủ; tái ức chế; kháng thuốc Chunk transition-probs
4 Ba người cùng trạng thái A, B, C: cùng ô trong mô hình đoàn hệ, khác nguy cơ trong mô phỏng vi mô Chunk l02-memory
5 Một số U cho nguy cơ cạnh tranh Chia đoạn [0, 1] thành chết, đổi trạng thái, kháng thuốc, ở yên; vì sao không dùng số riêng Hình vẽ tay đoạn [0, 1]
6 Quy tắc theo dõi Hướng dẫn quốc gia và bản rút gọn trong mô hình; giá đơn vị từ bảng giá chung; chuyển không cần thiết ở thời dolutegravir Sơ đồ mô hình (tổng quan)
7 Bộ máy vòng lặp Đọc run_microsim_loop() theo ba bước Chunk run-microsim-loop
8 Xác minh Kiểm tra có thể thất bại và kiểm tra luôn đúng; bảng đếm bước chuyển Chunk l02-checks
9 Lịch sử sống và vết Mỗi người một con đường; cộng lại thành vết figs/l02-histories.png, figs/l02-trace.png
10 Dùng chung số ngẫu nhiên Cùng quần thể, cùng ma trận u; chênh lệch theo cặp Chunk l02-two-strategies
11 Kết luận và chuyển tiếp Chi phí chênh lệch rõ ngay, lợi ích sức khỏe chìm trong nhiễu: cần nhiều người hơn và mã nhanh hơn (Bài 3) Bảng kết quả