pvisim: parameters, simulator, claims records, tests; calibration of all worlds to Medicare targets

- latent-severity DGP with physicians (skill, style), policies A/B1/B2/sequential, common random numbers
- sharp null exact at the patient level (tested)
- 01_calibrate.R: joint logit-scale update; all worlds match early PVI 3.2%, late PVI 22.5/3.6%, CLTI 7.8%, death 35%
- W4 deferral target 15% (trial-like crossover); post-CLTI PVI lowered so late PVI is mostly elective

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 08:15:18 -04:00
co-authored by Claude Opus 5.5
parent eb886d5505
commit 5c4407f876
11 changed files with 760 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# Calibrate each world's natural course to the Medicare targets (plan section 5.4).
#
# For every world, under the null scenario, solve one intercept per target, cycling
# until all targets hold. Every world gets the same targets, so worlds differ only in
# the threat they switch on, not in base rates.
#
# Usage: Rscript simulations/01_calibrate.R [n] [world ...]
# Writes simulations/config/calibrated.yml (merged over existing entries).
pkgload::load_all("pvisim", quiet = TRUE)
suppressPackageStartupMessages(library(data.table))
args <- commandArgs(TRUE)
n <- if (length(args) && grepl("^[0-9e.]+$", args[1])) as.numeric(args[1]) else 1e5
worlds <- setdiff(args, as.character(args[1]))
if (!length(worlds)) worlds <- names(yaml::read_yaml(default_params_path())$worlds)
H <- 53L # Sorber 2023 median follow-up, about 4.4 years
# Natural-course statistics that the targets are defined on.
stats_of <- function(par) {
pop <- make_population(par, n = n, seed = 20261027L)
ev <- simulate_course(pop, par, policy("natural"), seed = 20261028L, record = FALSE)$events
early <- ev$pvi_elective %in% TRUE & ev$t_pvi < par$size$window
late_early <- !is.na(ev$t_rr) & ev$t_rr < H
late_not <- !is.na(ev$t_pvi) & ev$t_pvi >= par$size$window & ev$t_pvi < H
list(early = mean(early),
late_not = mean(late_not[!early]),
late_early = mean(late_early[early]),
clti_not = mean((!is.na(ev$t_clti_code) & ev$t_clti_code < H)[!early]),
death = cum_inc(ev$t_death))
}
# target name -> parameter path and value (plan 5.4)
targets <- list(
early = list(path = c("treatment", "window", "intercept"), value = 0.032),
late_not = list(path = c("treatment", "deferred", "intercept"), value = 0.036),
late_early = list(path = c("outcomes", "repeat_revasc", "intercept"), value = 0.225),
clti_not = list(path = c("outcomes", "clti", "intercept"), value = 0.078),
death = list(path = c("outcomes", "death", "intercept"), value = 0.35)
)
set_path <- function(lst, path, value) {
if (length(path) == 1) { lst[[path]] <- value; return(lst) }
lst[[path[1]]] <- set_path(if (is.null(lst[[path[1]]])) list() else lst[[path[1]]], path[-1], value)
lst
}
get_path <- function(lst, path) Reduce(function(x, p) x[[p]], path, lst)
# Each target depends mainly on its own intercept, and for rare monthly events
# logit(rate) moves about one-for-one with the intercept. So update all intercepts
# together: b <- b - damp * (logit(stat) - logit(target)), until every target is within
# `tol` on the logit scale. Common random numbers make each evaluation deterministic.
calibrate_world <- function(world, max_iter = 25, tol = 0.02, damp = 0.9) {
base <- load_params(world, "null", calibrated = FALSE)
for (tg in names(base$calibration_targets)) targets[[tg]]$value <- base$calibration_targets[[tg]]
ov <- list()
for (tg in names(targets)) ov <- set_path(ov, targets[[tg]]$path, get_path(base, targets[[tg]]$path))
for (it in seq_len(max_iter)) {
s <- stats_of(load_params(world, "null", overrides = ov, calibrated = FALSE))
gap <- sapply(names(targets), function(tg) qlogis(s[[tg]]) - qlogis(targets[[tg]]$value))
cat(sprintf("%s iter %2d: %s\n", world, it,
paste(sprintf("%s %.3f", names(s), unlist(s)), collapse = " ")))
if (all(abs(gap) < tol)) break
for (tg in names(targets)) {
b <- get_path(ov, targets[[tg]]$path)
ov <- set_path(ov, targets[[tg]]$path, round(b - damp * gap[[tg]], 4))
}
}
if (any(abs(gap) >= tol)) warning(world, ": not converged; largest logit gap ", round(max(abs(gap)), 3))
ov
}
out_path <- "simulations/config/calibrated.yml"
cal <- if (file.exists(out_path)) yaml::read_yaml(out_path) else list()
for (w in worlds) {
t0 <- Sys.time()
cal[[w]] <- calibrate_world(w)
cat(sprintf("%s done in %.1f min\n", w, as.numeric(Sys.time() - t0, units = "mins")))
yaml::write_yaml(cal, out_path)
}
+104
View File
@@ -0,0 +1,104 @@
W0:
treatment:
window:
intercept: -6.3552
deferred:
intercept: -7.7172
outcomes:
repeat_revasc:
intercept: -5.6375
clti:
intercept: -7.4903
death:
intercept: -5.6504
W4:
treatment:
window:
intercept: -6.3535
deferred:
intercept: -6.7744
outcomes:
repeat_revasc:
intercept: -5.6429
clti:
intercept: -7.4903
death:
intercept: -5.6542
W1:
treatment:
window:
intercept: -6.3552
deferred:
intercept: -7.7172
outcomes:
repeat_revasc:
intercept: -5.7951
clti:
intercept: -7.4903
death:
intercept: -5.6504
W1_rho0:
treatment:
window:
intercept: -6.3552
deferred:
intercept: -7.7172
outcomes:
repeat_revasc:
intercept: -5.7187
clti:
intercept: -7.4903
death:
intercept: -5.6504
W2:
treatment:
window:
intercept: -7.3097
deferred:
intercept: -8.5615
outcomes:
repeat_revasc:
intercept: -5.5855
clti:
intercept: -7.4913
death:
intercept: -5.6505
W3:
treatment:
window:
intercept: -7.3134
deferred:
intercept: -8.5848
outcomes:
repeat_revasc:
intercept: -6.4032
clti:
intercept: -7.5232
death:
intercept: -5.649
W5:
treatment:
window:
intercept: -6.3552
deferred:
intercept: -7.7172
outcomes:
repeat_revasc:
intercept: -5.6375
clti:
intercept: -7.4903
death:
intercept: -5.6504
WR:
treatment:
window:
intercept: -7.3131
deferred:
intercept: -9.598
outcomes:
repeat_revasc:
intercept: -6.8121
clti:
intercept: -7.5233
death:
intercept: -5.6489
+26
View File
@@ -0,0 +1,26 @@
# Quick look: the natural course of each world against the calibration targets (plan 5.4).
pkgload::load_all("pvisim", quiet = TRUE)
library(data.table)
args <- commandArgs(TRUE); worlds <- if (length(args)) args else c("W0", "WR")
for (w in worlds) {
par <- load_params(w, "harm")
t0 <- Sys.time()
pop <- make_population(par, seed = 1L)
ev <- simulate_course(pop, par, policy("natural"), seed = 2L, record = FALSE)$events
ev <- merge(ev, pop$pat[, .(id, phys_id)], by = "id")
early <- !is.na(ev$t_pvi) & ev$t_pvi < 6 & ev$pvi_elective %in% TRUE
h <- 53 # Sorber median follow-up about 4.4 years
late <- !is.na(ev$t_pvi) & ev$t_pvi >= 6 | !is.na(ev$t_rr) & ev$t_rr >= 6
late <- late & pmin(ev$t_pvi * NA^(ev$t_pvi < 6), ifelse(!is.na(ev$t_rr) & ev$t_rr >= 6, ev$t_rr, NA), na.rm = TRUE) < h
clti <- !is.na(ev$t_clti_code) & ev$t_clti_code < h
phys <- ev[, .(n = .N, rate = mean(!is.na(t_pvi) & t_pvi < 6 & pvi_elective %in% TRUE)), by = phys_id]
sdr <- sd(phys$rate); out <- mean(phys$rate >= mean(phys$rate) + 2 * sdr)
cat(sprintf("\n%s (%.0fs)\n early PVI %.1f%% (target 3.2)\n physician rate: median %.1f%%, max %.0f%%, >=2SD outliers %.1f%% (0, 58, 5.6)\n",
w, as.numeric(Sys.time() - t0, units = "secs"), 100 * mean(early),
100 * median(phys$rate), 100 * max(phys$rate), 100 * out))
cat(sprintf(" late PVI by 4.4y: early %.1f%% vs not %.1f%% (22.5 vs 3.6)\n", 100 * mean(late[early]), 100 * mean(late[!early])))
cat(sprintf(" coded CLTI by 4.4y: early %.1f%% vs not %.1f%% (16.4 vs 7.8)\n", 100 * mean(clti[early]), 100 * mean(clti[!early])))
cat(sprintf(" death by 5y %.1f%% (about 35) MALE by 5y %.1f%% amputation %.2f%% disease composite %.1f%%\n",
100 * cum_inc(ev$t_death), 100 * cum_inc(ev$t_male), 100 * cum_inc(ev$t_amp), 100 * cum_inc(ev$t_disease)))
cat(sprintf(" NCO1 %.1f%% NCO2 %.1f%% NCE %.1f%%\n", 100 * cum_inc(ev$t_nco1), 100 * cum_inc(ev$t_nco2), 100 * cum_inc(ev$t_nce)))
}