Nothing
# [AI-generated: Claude Sonnet 4.6, 2026-06-15]
# [Prompt summary: Plasmode simulation pipeline for Goals 1 and 2 - RESI bootstrap CI evaluation]
# ============================================================
# Internal helpers
# ============================================================
# Extract aligned RESI point estimates and CI bounds from one resi object
# for a specified set of row names (terms).
.simExtractRows <- function(resi_obj, table_name, terms, ci_lo, ci_hi) {
tab <- resi_obj[[table_name]]
if (is.null(tab)) {
na_vec <- setNames(rep(NA_real_, length(terms)), terms)
return(list(resi = na_vec, lo = na_vec, hi = na_vec))
}
idx <- match(terms, rownames(tab))
list(
resi = setNames(tab[idx, "RESI"], terms),
lo = setNames(if (ci_lo %in% colnames(tab)) tab[idx, ci_lo] else
rep(NA_real_, length(terms)), terms),
hi = setNames(if (ci_hi %in% colnames(tab)) tab[idx, ci_hi] else
rep(NA_real_, length(terms)), terms)
)
}
# Overwrite RESI column in a resi_pe object with direct (population) formulas:
# T-stat -> S = t / sqrt(n) (signed)
# Z-stat -> S = z / sqrt(n) (signed)
# F-stat -> S = sqrt(F * df / n) (no rdf or df correction)
# Chisq -> S = sqrt(chisq / n) (no df subtraction)
# This treats the full-dataset test statistics as the true population parameters
# rather than using small-sample-corrected estimators from resi_pe.
.simDirectRESI <- function(pe_obj, n, rdf = NULL) {
if (!is.null(pe_obj$coefficients)) {
tab <- pe_obj$coefficients
if ("t value" %in% colnames(tab)) {
# For parametric lm, rdf = n-p is passed so that S_true = t/sqrt(rdf),
# which targets beta/sqrt(diag(n*(X'X)^{-1}*mean(resid^2))) -- the
# population RESI using sigma^2 = mean(resid^2) with no df correction.
denom <- if (!is.null(rdf)) sqrt(rdf) else sqrt(n)
tab[, "RESI"] <- tab[, "t value"] / denom
} else if ("z value" %in% colnames(tab)) {
tab[, "RESI"] <- tab[, "z value"] / sqrt(n)
}
pe_obj$coefficients <- tab
}
if (!is.null(pe_obj$anova)) {
tab <- pe_obj$anova
if ("F" %in% colnames(tab)) {
# Same logic: S_true = sqrt(F * df / rdf) when rdf is supplied.
denom <- if (!is.null(rdf)) rdf else n
tab[, "RESI"] <- sqrt(tab[, "F"] * tab[, "Df"] / denom)
} else if ("Chisq" %in% colnames(tab)) {
tab[, "RESI"] <- sqrt(tab[, "Chisq"] / n)
}
pe_obj$anova <- tab
}
pe_obj
}
# Compute per-term simulation metrics from a list of resi objects.
# Returns a data.frame with rows = terms and columns = bias, mse, coverage, width.
.simComputeMetrics <- function(reps, table_name, true_resi, ci_lo, ci_hi,
exclude_rows = NULL) {
terms <- rownames(true_resi)
if (!is.null(exclude_rows)) terms <- setdiff(terms, exclude_rows)
if (length(terms) == 0L) return(NULL)
extracted <- lapply(reps, .simExtractRows,
table_name = table_name, terms = terms,
ci_lo = ci_lo, ci_hi = ci_hi)
resi_mat <- do.call(rbind, lapply(extracted, `[[`, "resi"))
lo_mat <- do.call(rbind, lapply(extracted, `[[`, "lo"))
hi_mat <- do.call(rbind, lapply(extracted, `[[`, "hi"))
tv <- true_resi[terms, "RESI"]
diff_mat <- sweep(resi_mat, 2L, tv, "-")
in_ci <- sweep(lo_mat, 2L, tv, "<=") & sweep(hi_mat, 2L, tv, ">=")
# upper_coverage: P(hi >= tv) -- CI upper bound is at or above the true value
# lower_coverage: P(lo <= tv) -- CI lower bound is at or below the true value
upper_cov_mat <- sweep(hi_mat, 2L, tv, ">=")
lower_cov_mat <- sweep(lo_mat, 2L, tv, "<=")
data.frame(
bias = colMeans(diff_mat, na.rm = TRUE),
mse = colMeans(diff_mat ^ 2L, na.rm = TRUE),
empirical_sd = apply(resi_mat, 2L, sd, na.rm = TRUE),
coverage = colMeans(in_ci, na.rm = TRUE),
upper_coverage = colMeans(upper_cov_mat, na.rm = TRUE),
lower_coverage = colMeans(lower_cov_mat, na.rm = TRUE),
width = colMeans(hi_mat - lo_mat, na.rm = TRUE),
row.names = terms,
stringsAsFactors = FALSE
)
}
# ============================================================
# insurancePlasmodeSim
# ============================================================
#' Insurance Plasmode Simulation for RESI Evaluation
#'
#' Runs a plasmode simulation study using the \code{\link{insurance}} dataset to
#' evaluate RESI confidence interval performance. In each replicate, \code{n}
#' observations are resampled with replacement from the full insurance dataset
#' (\emph{N} = 1338). The RESI point estimates from \code{\link{resi_pe}} applied
#' to the full dataset are treated as the true parameter values for computing bias,
#' MSE, CI coverage, and CI width.
#'
#' Two models are evaluated:
#' \itemize{
#' \item \strong{lm}: \code{log10(charges) ~ ns(age, df=3) * sex + bmi + smoker + region}
#' \item \strong{glm}: \code{I(charges > 10000) ~ ns(age, df=3) * sex + bmi + smoker + region}
#' with \code{family = binomial()}
#' }
#' Each model is evaluated under both parametric (\code{vcovfunc = stats::vcov}) and
#' robust (\code{vcovfunc = sandwich::vcovHC}) variance settings, yielding four
#' simulation conditions.
#'
#' Parallelization is via \code{\link[parallel]{mclapply}}, which uses forking and is
#' not supported on Windows (falls back to sequential evaluation on Windows).
#'
#' @param nsim Integer, number of simulation replicates per (setting, \code{n}) cell.
#' Default 1000. Use 10 for initial testing.
#' @param n.vec Integer vector of sample sizes. Default
#' \code{c(50, 100, 200, 500, 1000, 2000, 5000)}.
#' @param nboot Integer, bootstrap replicates per internal \code{\link{resi}} call.
#' Default 500. Use 10 for initial testing. Ignored when \code{ci.method != "boot"}.
#' @param alpha Numeric, CI significance level. Default 0.05.
#' @param ci.method Character, CI method passed to \code{\link{resi}}. One of
#' \code{"boot"} (bootstrap, default), \code{"normal"} (asymptotic truncated-normal),
#' or \code{"qf"} (asymptotic quadratic-form / Imhof). When \code{ci.method != "boot"},
#' \code{nboot} is ignored.
#' @param output.dir Character, path to the directory where all results are saved.
#' Created if it does not exist. Defaults to \code{"resiBootSim"},
#' \code{"resiAsympNormalSim"}, or \code{"resiAsympQFSim"} based on
#' \code{ci.method} when \code{NULL}.
#' @param fixed.knots Logical. If \code{TRUE}, spline knots are fixed at the
#' empirical tertiles of \code{age} in the full insurance dataset rather than
#' re-selected by \code{df = 3} in each bootstrap sample. Default \code{FALSE}.
#' @param mc.cores.settings Integer, cores for the outer
#' \code{mclapply} over (setting \eqn{\times} sample size) combinations. Default 1.
#' @param mc.cores.reps Integer, cores for the inner \code{mclapply} over simulation
#' replicates within each (setting, \code{n}) cell. Default 1.
#'
#' @return Invisibly returns the summary metrics \code{data.frame}. Side effects:
#' \itemize{
#' \item \code{output.dir/sim_raw/<setting>_n<n>.rds}: list of per-replicate
#' \code{anova} and \code{coefficients} tables.
#' \item \code{output.dir/summary_table.rds}: combined metrics table with columns
#' \code{model, vcov, n, n_success, table, term, bias, mse, coverage, width}.
#' }
#' @seealso \code{\link{simFigures}}, \code{\link{simCompareMethodsFigures}},
#' \code{\link{resi}}, \code{\link{resi_pe}}
#' @importFrom parallel mclapply
#' @importFrom splines ns
#' @importFrom sandwich vcovHC
#' @importFrom stats lm glm vcov binomial
#' @export
insurancePlasmodeSim <- function(nsim = 1000L,
n.vec = c(50, 100, 200, 500, 1000, 2000, 5000),
nboot = 500L,
alpha = 0.05,
ci.method = c("boot", "normal", "qf", "cf"),
output.dir = NULL,
fixed.knots = FALSE,
mc.cores.settings = 1L,
mc.cores.reps = 1L) {
ci.method <- match.arg(ci.method)
if (is.null(output.dir)) {
output.dir <- switch(ci.method,
boot = "resiBootSim",
normal = "resiAsympNormalSim",
qf = "resiAsympQFSim",
cf = "resiAsympCFSim"
)
}
insurance <- RESI::insurance
# Pre-compute expected factor levels for plasmode resampling check
.fvars <- c("sex", "smoker", "region")
.flevels <- lapply(.fvars, function(v) unique(insurance[[v]]))
names(.flevels) <- .fvars
ci_lo <- paste0(alpha / 2 * 100, "%")
ci_hi <- paste0((1 - alpha / 2) * 100, "%")
if (fixed.knots) {
.age_knots <- quantile(insurance$age, c(1/3, 2/3))
.age_bk <- range(insurance$age)
lm_formula <- eval(bquote(log10(charges) ~ splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) * sex + bmi + smoker + region))
glm_formula <- eval(bquote(I(charges > 15000) ~ splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) * sex + bmi + smoker + region))
} else {
lm_formula <- log10(charges) ~ splines::ns(age, df = 3) * sex + bmi + smoker + region
glm_formula <- I(charges > 15000) ~ splines::ns(age, df = 3) * sex + bmi + smoker + region
}
model_settings <- list(
list(type = "lm", label = "lm_parametric",
formula = lm_formula, family = NULL,
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "lm", label = "lm_robust",
formula = lm_formula, family = NULL,
vcov_name = "robust", vcovfunc = sandwich::vcovHC),
list(type = "glm", label = "glm_parametric",
formula = glm_formula, family = stats::binomial(),
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "glm", label = "glm_robust",
formula = glm_formula, family = stats::binomial(),
vcov_name = "robust", vcovfunc = sandwich::vcovHC)
)
# --- True RESI values from the full dataset ------------------------------------
message("Computing true RESI values from full insurance dataset (n = ",
nrow(insurance), ")...")
true_vals <- lapply(model_settings, function(s) {
.formula <- s$formula
.family <- s$family
full_mod <- if (s$type == "lm") {
m <- lm(.formula, data = insurance)
m$call[["formula"]] <- .formula
m
} else {
m <- glm(.formula, data = insurance, family = .family)
m$call[["formula"]] <- .formula
m$call[["family"]] <- .family
m
}
# Robust true values: use HC0 (no hat-value correction) so that the
# small-sample inflation from HC3 hat weights does not enter S_true.
# Parametric lm true values: pass rdf = n-p so that S_true = t/sqrt(rdf),
# targeting sigma^2 = mean(resid^2) (population value, no df correction).
true_vcovfunc <- if (s$vcov_name == "robust") {
function(x) sandwich::vcovHC(x, type = "HC0")
} else {
s$vcovfunc
}
rdf_true <- if (s$type == "lm" && s$vcov_name == "parametric") {
full_mod$df.residual
} else {
NULL
}
.simDirectRESI(resi_pe(full_mod, data = insurance, vcovfunc = true_vcovfunc),
n = nrow(insurance), rdf = rdf_true)
})
names(true_vals) <- sapply(model_settings, `[[`, "label")
# --- Output directories --------------------------------------------------------
sim_raw_dir <- file.path(output.dir, "sim_raw")
dir.create(sim_raw_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(file.path(output.dir, "figures"), recursive = TRUE, showWarnings = FALSE)
# --- (setting x n) grid -------------------------------------------------------
grid <- expand.grid(
setting_idx = seq_along(model_settings),
n = n.vec,
stringsAsFactors = FALSE
)
message("Running ", nsim, " replicates for each of ", nrow(grid),
" (setting x sample size) combinations ...")
# --- Outer mclapply over (setting x n) ----------------------------------------
all_metrics <- parallel::mclapply(seq_len(nrow(grid)), function(g) {
s_idx <- grid$setting_idx[g]
n <- grid$n[g]
s <- model_settings[[s_idx]]
tv <- true_vals[[s_idx]]
setting_label <- paste0(s$label, "_n", n)
# --- Inner mclapply over replicates -----------------------------------------
reps_raw <- parallel::mclapply(seq_len(nsim), function(i) {
# Resample with replacement until the plasmode sample is well-conditioned:
# (a) all factor levels present; (b) for GLM, both binary outcome values
# must appear within each smoker stratum (prevents complete separation).
repeat {
sample_data <- insurance[sample(nrow(insurance), n, replace = TRUE), ]
ok <- all(vapply(.fvars, function(v)
all(.flevels[[v]] %in% sample_data[[v]]), logical(1L)))
if (ok && s$type == "glm") {
biny <- as.integer(sample_data$charges > 15000)
ok <- length(unique(biny)) > 1L &&
all(tapply(biny, sample_data$smoker,
function(x) length(unique(x)) > 1L))
}
if (ok) break
}
# Embed the evaluated formula (and family for glm) directly into mod$call
# so that resi()'s internal update() calls can resolve them without needing
# 's' to be in scope (it is not accessible inside boot::boot / resi_stat).
.formula <- s$formula
.family <- s$family
mod <- tryCatch({
m <- if (s$type == "lm") {
lm(.formula, data = sample_data)
} else {
glm(.formula, data = sample_data, family = .family)
}
m$call[["formula"]] <- .formula
if (s$type == "glm") m$call[["family"]] <- .family
m
}, error = function(e) NULL)
if (is.null(mod)) return(NULL)
tryCatch(
resi(mod, data = sample_data, nboot = nboot, vcovfunc = s$vcovfunc,
alpha = alpha, store.boot = FALSE, ci.method = ci.method),
error = function(e) NULL
)
}, mc.cores = mc.cores.reps)
reps <- Filter(Negate(is.null), reps_raw)
n_success <- length(reps)
# Save minimal per-replicate output (anova + coefficients tables only)
reps_to_save <- lapply(reps, function(r) {
list(anova = r$anova, coefficients = r$coefficients)
})
# Retry loop: on macOS, SIGCHLD from mclapply child processes can interrupt
# gzfile() with "Interrupted system call"; a brief pause + retry resolves it.
for (.attempt in seq_len(5L)) {
saved <- tryCatch({
Sys.sleep(0.05 * .attempt)
saveRDS(reps_to_save,
file = file.path(sim_raw_dir, paste0(setting_label, ".rds")))
TRUE
}, error = function(e) FALSE)
if (saved) break
if (.attempt == 5L)
warning("saveRDS failed after 5 attempts for ", setting_label)
}
if (n_success == 0L) {
warning("All replicates failed for setting: ", setting_label)
return(NULL)
}
# --- Compute metrics --------------------------------------------------------
anova_metrics <- .simComputeMetrics(reps, "anova",
tv$anova, ci_lo, ci_hi,
exclude_rows = "Residuals")
coef_metrics <- .simComputeMetrics(reps, "coefficients",
tv$coefficients, ci_lo, ci_hi,
exclude_rows = "(Intercept)")
rows <- list()
if (!is.null(anova_metrics)) {
rows[["anova"]] <- data.frame(
model = s$type,
vcov = s$vcov_name,
n = n,
n_success = n_success,
table = "anova",
term = rownames(anova_metrics),
anova_metrics,
row.names = NULL,
stringsAsFactors = FALSE
)
}
if (!is.null(coef_metrics)) {
rows[["coef"]] <- data.frame(
model = s$type,
vcov = s$vcov_name,
n = n,
n_success = n_success,
table = "coefficients",
term = rownames(coef_metrics),
coef_metrics,
row.names = NULL,
stringsAsFactors = FALSE
)
}
do.call(rbind, rows)
}, mc.cores = mc.cores.settings)
# --- Combine and save summary table -------------------------------------------
summary_table <- do.call(rbind, Filter(Negate(is.null), all_metrics))
saveRDS(summary_table, file = file.path(output.dir, "summary_table.rds"))
message("Done. Results saved to: ", output.dir)
invisible(summary_table)
}
# ============================================================
# simRecomputeSummary
# ============================================================
#' Recompute Summary Table from Raw Simulation Output
#'
#' Reads the per-replicate raw \code{.rds} files saved by
#' \code{\link{insurancePlasmodeSim}} and recomputes the summary metrics table
#' (bias, MSE, coverage, upper_coverage, lower_coverage, width). Use this
#' whenever \code{.simComputeMetrics} has been updated (e.g. new metrics added)
#' without needing to rerun the full simulation.
#'
#' True RESI values are re-estimated from the full \code{\link{insurance}} dataset
#' using the same formulae and variance functions as the original simulation.
#'
#' @param output.dir Character, directory containing simulation output.
#' Default \code{"resiBootSim"}.
#' @param alpha Numeric, CI significance level used in the original simulation.
#' Default 0.05.
#' @param fixed.knots Logical. Must match the value used in the original
#' \code{\link{insurancePlasmodeSim}} call so that the true RESI values are
#' computed from the same model formula. Default \code{FALSE}.
#'
#' @return Invisibly returns the updated summary \code{data.frame}.
#' Overwrites \code{output.dir/summary_table.rds}.
#' @seealso \code{\link{insurancePlasmodeSim}}, \code{\link{simFigures}}
#' @importFrom splines ns
#' @importFrom sandwich vcovHC
#' @importFrom stats lm glm vcov binomial
#' @export
simRecomputeSummary <- function(output.dir = "resiBootSim",
alpha = 0.05,
fixed.knots = FALSE) {
insurance <- RESI::insurance
ci_lo <- paste0(alpha / 2 * 100, "%")
ci_hi <- paste0((1 - alpha / 2) * 100, "%")
if (fixed.knots) {
.age_knots <- quantile(insurance$age, c(1/3, 2/3))
.age_bk <- range(insurance$age)
lm_formula <- eval(bquote(log10(charges) ~ splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) * sex + bmi + smoker + region))
glm_formula <- eval(bquote(I(charges > 15000) ~ splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) * sex + bmi + smoker + region))
} else {
lm_formula <- log10(charges) ~ splines::ns(age, df = 3) * sex + bmi + smoker + region
glm_formula <- I(charges > 15000) ~ splines::ns(age, df = 3) * sex + bmi + smoker + region
}
model_settings <- list(
list(type = "lm", label = "lm_parametric",
formula = lm_formula, family = NULL,
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "lm", label = "lm_robust",
formula = lm_formula, family = NULL,
vcov_name = "robust", vcovfunc = sandwich::vcovHC),
list(type = "glm", label = "glm_parametric",
formula = glm_formula, family = stats::binomial(),
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "glm", label = "glm_robust",
formula = glm_formula, family = stats::binomial(),
vcov_name = "robust", vcovfunc = sandwich::vcovHC)
)
message("Recomputing true RESI values from full insurance dataset...")
true_vals <- lapply(model_settings, function(s) {
.formula <- s$formula
.family <- s$family
full_mod <- if (s$type == "lm") {
m <- lm(.formula, data = insurance)
m$call[["formula"]] <- .formula
m
} else {
m <- glm(.formula, data = insurance, family = .family)
m$call[["formula"]] <- .formula
m$call[["family"]] <- .family
m
}
true_vcovfunc <- if (s$vcov_name == "robust") {
function(x) sandwich::vcovHC(x, type = "HC0")
} else {
s$vcovfunc
}
rdf_true <- if (s$type == "lm" && s$vcov_name == "parametric") {
full_mod$df.residual
} else {
NULL
}
.simDirectRESI(resi_pe(full_mod, data = insurance, vcovfunc = true_vcovfunc),
n = nrow(insurance), rdf = rdf_true)
})
names(true_vals) <- sapply(model_settings, `[[`, "label")
sim_raw_dir <- file.path(output.dir, "sim_raw")
raw_files <- list.files(sim_raw_dir, pattern = "\\.rds$", full.names = TRUE)
if (length(raw_files) == 0L)
stop("No raw .rds files found in: ", sim_raw_dir)
setting_labels <- sapply(model_settings, `[[`, "label")
all_metrics <- lapply(raw_files, function(f) {
fname <- sub("\\.rds$", "", basename(f))
n <- suppressWarnings(as.integer(sub(".*_n", "", fname)))
label <- sub("_n[0-9]+$", "", fname)
s_idx <- which(setting_labels == label)
if (length(s_idx) == 0L || is.na(n)) {
warning("Could not parse setting/n from filename: ", basename(f))
return(NULL)
}
s <- model_settings[[s_idx]]
tv <- true_vals[[s_idx]]
reps_raw <- readRDS(f)
n_success <- length(reps_raw)
if (n_success == 0L) return(NULL)
anova_metrics <- .simComputeMetrics(reps_raw, "anova",
tv$anova, ci_lo, ci_hi,
exclude_rows = "Residuals")
coef_metrics <- .simComputeMetrics(reps_raw, "coefficients",
tv$coefficients, ci_lo, ci_hi,
exclude_rows = "(Intercept)")
rows <- list()
if (!is.null(anova_metrics)) {
rows[["anova"]] <- data.frame(
model = s$type,
vcov = s$vcov_name,
n = n,
n_success = n_success,
table = "anova",
term = rownames(anova_metrics),
anova_metrics,
row.names = NULL,
stringsAsFactors = FALSE
)
}
if (!is.null(coef_metrics)) {
rows[["coef"]] <- data.frame(
model = s$type,
vcov = s$vcov_name,
n = n,
n_success = n_success,
table = "coefficients",
term = rownames(coef_metrics),
coef_metrics,
row.names = NULL,
stringsAsFactors = FALSE
)
}
do.call(rbind, rows)
})
summary_table <- do.call(rbind, Filter(Negate(is.null), all_metrics))
saveRDS(summary_table, file = file.path(output.dir, "summary_table.rds"))
message("Done. Updated summary_table.rds saved to: ", output.dir)
invisible(summary_table)
}
# ============================================================
# simEstimatorFigures
# ============================================================
#' Estimator Comparison Figures: t2S/f2S vs z2S/chisq2S
#'
#' Reads the per-replicate raw \code{.rds} files from one simulation directory and
#' produces Bias + MSE comparison figures contrasting the RESI estimators actually
#' used for \code{lm} models against the simpler z-statistic / chi-squared alternatives:
#' \itemize{
#' \item \strong{Coefficients table}: \code{\link{t2S}} (used) vs \code{\link{z2S}} (alternative)
#' \item \strong{Anova table}: \code{\link{f2S}} (used) vs \code{\link{chisq2S}} (alternative)
#' }
#' Only \code{lm} models are included; GLM models are skipped because z2S and
#' chisq2S are the natural estimators there.
#'
#' @param sim.dir Character, directory containing simulation output with a
#' \code{sim_raw/} sub-directory. Default \code{"resiBootSim"}.
#' @param figures.dir Character, output directory for PDFs.
#' Default \code{file.path(sim.dir, "figures", "estimator_compare")}.
#' @param alpha Numeric, nominal level (used only in figure titles). Default 0.05.
#' @param fixed.knots Logical. Must match the value used in the original
#' \code{\link{insurancePlasmodeSim}} call. Default \code{FALSE}.
#'
#' @return Invisibly returns the combined estimator-comparison \code{data.frame}.
#' @seealso \code{\link{simCompareMethodsFigures}}, \code{\link{simRecomputeSummary}}
#' @importFrom grDevices pdf dev.off
#' @importFrom graphics plot lines points abline legend par axis plot.new layout
#' @importFrom stats lm vcov
#' @importFrom sandwich vcovHC
#' @importFrom splines ns
#' @export
simEstimatorFigures <- function(
sim.dir = "resiBootSim",
figures.dir = NULL,
alpha = 0.05,
fixed.knots = FALSE
) {
if (is.null(figures.dir))
figures.dir <- file.path(sim.dir, "figures", "estimator_compare")
dir.create(figures.dir, recursive = TRUE, showWarnings = FALSE)
insurance <- RESI::insurance
# ---- Formula ---------------------------------------------------------------
if (fixed.knots) {
.age_knots <- quantile(insurance$age, c(1/3, 2/3))
.age_bk <- range(insurance$age)
lm_formula <- eval(bquote(
log10(charges) ~ splines::ns(age, knots = .(.age_knots),
Boundary.knots = .(.age_bk)) * sex + bmi + smoker + region))
} else {
lm_formula <- log10(charges) ~ splines::ns(age, df = 3) * sex + bmi + smoker + region
}
# ---- lm settings only ------------------------------------------------------
model_settings <- list(
list(label = "lm_parametric", vcov_name = "parametric",
vcovfunc = stats::vcov),
list(label = "lm_robust", vcov_name = "robust",
vcovfunc = sandwich::vcovHC)
)
# ---- True S from full insurance dataset ------------------------------------
message("Computing true RESI values from full insurance dataset...")
true_vals <- lapply(model_settings, function(s) {
m <- lm(lm_formula, data = insurance)
m$call[["formula"]] <- lm_formula
tv_vcov <- if (s$vcov_name == "robust") {
function(x) sandwich::vcovHC(x, type = "HC0")
} else {
s$vcovfunc
}
rdf_tv <- if (s$vcov_name == "parametric") m$df.residual else NULL
.simDirectRESI(resi_pe(m, data = insurance, vcovfunc = tv_vcov),
n = nrow(insurance), rdf = rdf_tv)
})
names(true_vals) <- sapply(model_settings, `[[`, "label")
# ---- Colours / layout constants --------------------------------------------
est_cols <- c("t2S / f2S" = "#1B6CA8", "z2S / chisq2S" = "#CC5500")
row_h <- 3.2
leg_h <- 0.55
raw_dir <- file.path(sim.dir, "sim_raw")
# ---- Helper: extract one column from anova/coef table per replicate --------
.extract_col <- function(reps, tbl_name, terms, col) {
m <- matrix(NA_real_, nrow = length(reps), ncol = length(terms),
dimnames = list(NULL, terms))
for (ri in seq_along(reps)) {
tab <- reps[[ri]][[tbl_name]]
idx <- match(terms, rownames(tab))
ok <- !is.na(idx)
if (any(ok)) m[ri, ok] <- tab[idx[ok], col]
}
m
}
# ---- Loop over lm settings -------------------------------------------------
all_combined <- list()
for (s in model_settings) {
lbl <- s$label
vtype <- s$vcov_name
tv <- true_vals[[lbl]]
raw_files <- list.files(raw_dir,
pattern = paste0("^", lbl, "_n[0-9]+\\.rds$"),
full.names = TRUE)
if (length(raw_files) == 0L) {
message("No raw files found for: ", lbl, " -- skipping")
next
}
# -- Per-n summaries -------------------------------------------------------
per_n <- lapply(raw_files, function(f) {
n_sim <- as.integer(sub(".*_n([0-9]+)\\.rds$", "\\1", basename(f)))
reps <- readRDS(f)
if (length(reps) == 0L) return(NULL)
rows <- list()
# Anova: f2S (RESI col) vs chisq2S(F*Df, Df, n)
a_terms <- setdiff(rownames(tv$anova), "Residuals")
if (length(a_terms) > 0L) {
resi_f <- .extract_col(reps, "anova", a_terms, "RESI")
resi_c <- matrix(NA_real_, nrow = length(reps), ncol = length(a_terms),
dimnames = list(NULL, a_terms))
for (ri in seq_along(reps)) {
tab <- reps[[ri]]$anova
idx <- match(a_terms, rownames(tab))
ok <- !is.na(idx)
if (any(ok)) {
F_i <- tab[idx[ok], "F"]
Df_i <- tab[idx[ok], "Df"]
resi_c[ri, ok] <- chisq2S(F_i * Df_i, Df_i, n_sim)
}
}
tv_a <- tv$anova[a_terms, "RESI"]
diff_f <- sweep(resi_f, 2L, tv_a, "-")
diff_c <- sweep(resi_c, 2L, tv_a, "-")
rows$anova <- rbind(
data.frame(model = "lm", vcov = vtype, n = n_sim, table = "anova",
term = a_terms, estimator = "t2S / f2S",
bias = colMeans(diff_f, na.rm = TRUE),
mse = colMeans(diff_f^2, na.rm = TRUE),
row.names = NULL, stringsAsFactors = FALSE),
data.frame(model = "lm", vcov = vtype, n = n_sim, table = "anova",
term = a_terms, estimator = "z2S / chisq2S",
bias = colMeans(diff_c, na.rm = TRUE),
mse = colMeans(diff_c^2, na.rm = TRUE),
row.names = NULL, stringsAsFactors = FALSE)
)
}
# Coefficients: t2S (RESI col) vs z2S(t, n)
c_terms <- setdiff(rownames(tv$coefficients), "(Intercept)")
if (length(c_terms) > 0L) {
coef_col <- "t value"
resi_t <- .extract_col(reps, "coefficients", c_terms, "RESI")
t_mat <- .extract_col(reps, "coefficients", c_terms, coef_col)
resi_z <- matrix(z2S(t_mat, n_sim), nrow = nrow(t_mat), ncol = ncol(t_mat),
dimnames = dimnames(t_mat))
tv_c <- tv$coefficients[c_terms, "RESI"]
diff_t <- sweep(resi_t, 2L, tv_c, "-")
diff_z <- sweep(resi_z, 2L, tv_c, "-")
rows$coef <- rbind(
data.frame(model = "lm", vcov = vtype, n = n_sim, table = "coefficients",
term = c_terms, estimator = "t2S / f2S",
bias = colMeans(diff_t, na.rm = TRUE),
mse = colMeans(diff_t^2, na.rm = TRUE),
row.names = NULL, stringsAsFactors = FALSE),
data.frame(model = "lm", vcov = vtype, n = n_sim, table = "coefficients",
term = c_terms, estimator = "z2S / chisq2S",
bias = colMeans(diff_z, na.rm = TRUE),
mse = colMeans(diff_z^2, na.rm = TRUE),
row.names = NULL, stringsAsFactors = FALSE)
)
}
do.call(rbind, rows)
})
combined <- do.call(rbind, Filter(Negate(is.null), per_n))
if (is.null(combined) || nrow(combined) == 0L) next
all_combined[[lbl]] <- combined
n_vals <- sort(unique(combined$n))
# -- Figures ---------------------------------------------------------------
for (tbl_name in c("anova", "coefficients")) {
sub <- combined[combined$table == tbl_name, ]
if (nrow(sub) == 0L) next
terms <- unique(sub$term)
n_terms <- length(terms)
tv_tbl <- if (tbl_name == "anova") tv$anova else tv$coefficients
fig_path <- file.path(figures.dir,
paste0("estimator_compare_", lbl, "_", tbl_name, ".pdf"))
grDevices::pdf(fig_path, width = 7, height = row_h * n_terms + leg_h)
# Layout: n_terms rows x 2 cols + 1 legend row
n_panels <- 2L * n_terms
lay_mat <- matrix(c(seq_len(n_panels),
rep(n_panels + 1L, 2L)),
nrow = n_terms + 1L, ncol = 2L, byrow = TRUE)
graphics::layout(lay_mat,
widths = c(3.2, 3.2),
heights = c(rep(row_h, n_terms), leg_h))
for (ti in seq_along(terms)) {
term <- terms[[ti]]
tdata <- sub[sub$term == term, ]
true_s <- if (term %in% rownames(tv_tbl)) tv_tbl[term, "RESI"] else NA_real_
term_title <- if (is.finite(true_s)) sprintf("%s: S=%.3f", term, true_s) else term
for (metric in c("bias", "mse")) {
ylab <- if (metric == "bias") "Bias" else "MSE"
ylim <- range(tdata[[metric]], na.rm = TRUE)
if (metric == "bias") ylim <- range(c(ylim, 0), na.rm = TRUE)
ylim <- ylim + diff(ylim) * c(-0.05, 0.05)
graphics::par(mar = c(3.2, 3.0, 2.2, 0.5), mgp = c(1.8, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = ylab,
main = if (metric == "bias") term_title else "",
log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
if (metric == "bias") graphics::abline(h = 0, lty = 2L, col = "gray40")
for (est in names(est_cols)) {
ed <- tdata[tdata$estimator == est, ]
ed <- ed[order(ed$n), ]
if (nrow(ed) == 0L) next
graphics::lines(ed$n, ed[[metric]], col = est_cols[[est]], lwd = 2L)
graphics::points(ed$n, ed[[metric]], col = est_cols[[est]], pch = 16L, cex = 0.9)
}
}
}
# Bottom legend
est_labels <- if (tbl_name == "anova") c("f2S (used)", "chisq2S") else c("t2S (used)", "z2S")
graphics::par(mar = c(0.2, 0.2, 0.2, 0.2))
graphics::plot.new()
graphics::legend("center",
legend = paste0(est_labels, " "),
col = unname(est_cols),
pch = 16L, lwd = 2L, lty = 1L,
bty = "n", cex = 0.9, ncol = 2L,
title = "Estimator", title.font = 2L)
grDevices::dev.off()
message("Saved: ", fig_path)
}
}
message("Estimator comparison figures saved to: ", figures.dir)
invisible(do.call(rbind, all_combined))
}
# ============================================================
# simFigures
# ============================================================
#' Simulation Performance Figures for RESI Evaluation
#'
#' Produces performance figures from the output of \code{\link{insurancePlasmodeSim}}.
#' Creates one PDF figure per (model type) \eqn{\times} (variance estimator) combination
#' (4 figures total by default). Each figure is a 2 \eqn{\times} 4 panel grid:
#' \itemize{
#' \item \strong{Top row}: Anova-table RESI metrics (Bias, MSE, CI Coverage, CI Width)
#' \item \strong{Bottom row}: Coefficients-table RESI metrics (same metrics; intercept excluded)
#' \item \strong{Colors}: one colored line per model term (high-contrast matte palette)
#' \item \strong{x-axis}: sample size on a log scale
#' }
#' Dashed reference lines are drawn at zero for Bias and at \eqn{1 - \alpha} for CI Coverage.
#'
#' @param output.dir Character, directory containing simulation output from
#' \code{\link{insurancePlasmodeSim}}. Default \code{"resiBootSim"}.
#' @param alpha Numeric, nominal CI level used for the coverage reference line.
#' Default 0.05.
#' @param ci.label Character, label for the CI method used in figure titles and file
#' names. Default \code{NULL}, which auto-detects from \code{output.dir}:
#' \code{"boot"} if the directory name contains "Boot"/"boot",
#' \code{"normal"} if it contains "Normal"/"normal",
#' \code{"qf"} if it contains "QF"/"qf", otherwise \code{basename(output.dir)}.
#'
#' @return Invisibly returns the summary metrics \code{data.frame}. Saves PDF figures
#' to \code{file.path(output.dir, "figures")}, named
#' \code{sim_<model>_<vcov>_<ci.label>.pdf}.
#' @seealso \code{\link{insurancePlasmodeSim}}
#' @importFrom grDevices pdf dev.off
#' @importFrom graphics plot lines points abline legend par axis plot.new layout
#' @export
simFigures <- function(output.dir = "resiBootSim",
alpha = 0.05,
ci.label = NULL) {
# Auto-detect ci.label from output.dir when not supplied
if (is.null(ci.label)) {
ci.label <- if (grepl("[Bb]oot", output.dir)) "boot" else
if (grepl("[Nn]ormal", output.dir)) "normal" else
if (grepl("[Qq][Ff]", output.dir)) "qf" else
basename(output.dir)
}
# High-contrast matte palette (matplotlib tab10 + extensions)
.sim_pal <- c(
"#1F77B4", "#D62728", "#2CA02C", "#FF7F0E", "#9467BD",
"#8C564B", "#E377C2", "#17BECF", "#BCBD22", "#7F7F7F",
"#AEC7E8", "#98DF8A"
)
summary_table <- readRDS(file.path(output.dir, "summary_table.rds"))
if (is.null(summary_table) || nrow(summary_table) == 0L)
stop("summary_table.rds in '", output.dir, "' is empty or NULL. ",
"Re-run insurancePlasmodeSim() (all simulation replicates may have ",
"failed -- check for 'All replicates failed' warnings).")
figures_dir <- file.path(output.dir, "figures")
dir.create(figures_dir, recursive = TRUE, showWarnings = FALSE)
n_vals <- sort(unique(summary_table$n))
metrics <- c("bias", "mse", "coverage", "width")
metric_labels <- c("Bias", "MSE", "CI Coverage", "CI Width")
for (mtype in c("lm", "glm")) {
for (vtype in c("parametric", "robust")) {
sub <- summary_table[summary_table$model == mtype &
summary_table$vcov == vtype, ]
if (is.null(sub) || nrow(sub) == 0L) next
fig_path <- file.path(
figures_dir,
paste0("sim_", mtype, "_", vtype, "_", ci.label, ".pdf")
)
anova_terms <- unique(sub$term[sub$table == "anova"])
coef_terms <- unique(sub$term[sub$table == "coefficients"])
anova_cols <- setNames(
.sim_pal[seq_along(anova_terms)], anova_terms)
coef_cols <- setNames(
.sim_pal[seq_along(coef_terms)], coef_terms)
row_info <- list(
list(table = "anova",
terms = anova_terms,
cols = anova_cols,
main_prefix = "Anova"),
list(table = "coefficients",
terms = coef_terms,
cols = coef_cols,
main_prefix = "Coef")
)
# Layout: 4 rows x 5 cols; coverage column (col 3) is split into two
# half-height panels (upper coverage on top, lower coverage on bottom).
# Row 1: 1 2 3 4 5 (anova bias, mse, upper cov, width, legend)
# Row 2: 1 2 6 4 5 (spans continue; anova lower cov)
# Row 3: 7 8 9 10 11 (coef bias, mse, upper cov, width, legend)
# Row 4: 7 8 12 10 11 (spans continue; coef lower cov)
grDevices::pdf(fig_path, width = 15, height = 7)
graphics::layout(
matrix(c( 1, 2, 3, 4, 5,
1, 2, 6, 4, 5,
7, 8, 9, 10, 11,
7, 8, 12, 10, 11), nrow = 4L, byrow = TRUE),
widths = c(rep(3, 4), 2.2),
heights = c(1, 1, 1, 1)
)
for (ri in row_info) {
sub_tbl <- sub[sub$table == ri$table, ]
cex_leg <- min(1.0, 9 / length(ri$terms))
# Helper: draw lines/points for all terms for a given metric
draw_lines <- function(metric) {
for (term in ri$terms) {
td <- sub_tbl[sub_tbl$term == term, ]
td <- td[order(td$n), ]
graphics::lines(td$n, td[[metric]], col = ri$cols[term], lwd = 2L)
graphics::points(td$n, td[[metric]], col = ri$cols[term], pch = 16L,
cex = 0.8)
}
}
# Shared y-range for both coverage sub-panels
cov_ylim <- range(
c(sub_tbl[["upper_coverage"]], sub_tbl[["lower_coverage"]],
1 - alpha/2 - 0.02, 1.01),
na.rm = TRUE
)
# --- Panel: Bias ---
bias_rows <- if (mtype == "glm") sub_tbl$n >= 500 else rep(TRUE, nrow(sub_tbl))
ylim <- range(c(sub_tbl[bias_rows, "bias"], 0), na.rm = TRUE)
graphics::par(mar = c(2.8, 2.8, 1.8, 0.4), mgp = c(1.7, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = "Bias",
main = paste(toupper(mtype), ri$main_prefix, "Bias",
paste0("(", vtype, ")")),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 0, lty = 2L, col = "gray40")
draw_lines("bias")
# --- Panel: MSE ---
mse_rows <- if (mtype == "glm") sub_tbl$n >= 500 else rep(TRUE, nrow(sub_tbl))
ylim <- if (any(is.finite(sub_tbl[mse_rows, "mse"]))) range(sub_tbl[mse_rows, "mse"], na.rm = TRUE) else c(0, 1)
graphics::par(mar = c(2.8, 2.8, 1.8, 0.4), mgp = c(1.7, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = "MSE",
main = paste(toupper(mtype), ri$main_prefix, "MSE",
paste0("(", vtype, ")")),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
draw_lines("mse")
# --- Panel: Upper Coverage (top half-panel, no x-axis) ---
graphics::par(mar = c(0.3, 2.8, 1.8, 0.4), mgp = c(1.7, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = cov_ylim,
xlab = "", ylab = "Upper Cov.",
main = paste(toupper(mtype), ri$main_prefix, "Coverage",
paste0("(", vtype, ")")),
log = "x", xaxt = "n", bty = "l")
graphics::abline(h = 1 - alpha/2, lty = 2L, col = "gray40")
draw_lines("upper_coverage")
# --- Panel: Width ---
width_rows <- if (mtype == "glm") sub_tbl$n >= 500 else rep(TRUE, nrow(sub_tbl))
ylim <- if (any(is.finite(sub_tbl[width_rows, "width"]))) range(sub_tbl[width_rows, "width"], na.rm = TRUE) else c(0, 1)
graphics::par(mar = c(2.8, 2.8, 1.8, 0.4), mgp = c(1.7, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = "CI Width",
main = paste(toupper(mtype), ri$main_prefix, "CI Width",
paste0("(", vtype, ")")),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
draw_lines("width")
# --- Legend panel (spans both coverage half-rows) ---
graphics::par(mar = c(0.5, 0.3, 0.5, 0.3))
graphics::plot.new()
graphics::legend(
"center",
legend = ri$terms,
col = ri$cols[ri$terms],
lwd = 2L,
pch = 16L,
bty = "n",
cex = cex_leg,
title = paste(ri$main_prefix, "terms"),
title.font = 2L
)
# --- Panel: Lower Coverage (bottom half-panel, with x-axis) ---
graphics::par(mar = c(2.8, 2.8, 0.3, 0.4), mgp = c(1.7, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = cov_ylim,
xlab = "Sample Size", ylab = "Lower Cov.",
main = "",
log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 1 - alpha/2, lty = 2L, col = "gray40")
draw_lines("lower_coverage")
}
grDevices::dev.off()
message("Saved: ", fig_path)
}
}
invisible(summary_table)
}
# ============================================================
# simCompareMethodsFigures
# ============================================================
# Internal: full delta-method sigma2S (including A/B chain-rule terms) for each
# coefficient in coef_idx (1-based index into coef(model)), using HC-type B.
# Returns named vector sigma2S_Th1_k = quad_k / S_k^2 (= n * resiSE_k^2).
.sigma2S_Th1 <- function(model, coef_idx, type = "HC0") {
is_lm <- inherits(model, "lm") && !inherits(model, "glm")
X <- model.matrix(model)
if (any(al <- is.na(coef(model)))) X <- X[, !al, drop = FALSE]
ef <- sandwich::estfun(model)
ef <- ef[, colnames(X), drop = FALSE]
p <- ncol(X); n <- nrow(X)
e <- residuals(model, "response")
sym_fn <- function(M) (M + t(M)) / 2
if (is_lm) {
phi <- summary(model)$sigma^2
m <- p + 1L
theta <- c(phi, coef(model))
psi_list <- lapply(seq_len(n), function(i) {
xi <- X[i, , drop = FALSE]; ei <- e[i]
cbind((ei^2 - phi) / (2*phi^2), matrix(ei * xi / phi, 1))
})
psiprime_list <- lapply(seq_len(n), function(i) {
xi <- X[i, , drop = FALSE]; ei <- e[i]
rbind(c((phi - 2*ei^2) / (2*phi^3), -ei * as.vector(xi) / phi^2),
cbind(matrix(-ei * as.vector(xi) / phi^2, p, 1),
-crossprod(xi) / phi))
})
} else {
m <- p
theta <- coef(model)
w_vec <- weights(model, type = "working")
psi_list <- lapply(seq_len(n), function(i)
matrix(ef[i, , drop = TRUE], 1))
psiprime_list <- lapply(seq_len(n), function(i)
-w_vec[i] * crossprod(X[i, , drop = FALSE]))
}
A_full <- sym_fn(-Reduce("+", psiprime_list) / n)
A_inv <- tryCatch(chol2inv(chol(A_full)), error = function(e2) solve(A_full))
h <- hatvalues(model)
omh <- pmax(1 - h, .Machine$double.eps)
sqrtw <- switch(toupper(type),
"HC0" = rep(1, n),
"HC1" = rep(sqrt(n / max(n - p, 1L)), n),
"HC2" = 1 / sqrt(omh),
"HC3" = 1 / omh,
rep(1, n)
)
B_full <- sym_fn(Reduce("+", lapply(seq_len(n), function(i) {
psi_i <- psi_list[[i]]
if (is_lm) psi_i[1L, seq(2L, m)] <- psi_i[1L, seq(2L, m)] * sqrtw[i]
else psi_i[1L, ] <- psi_i[1L, ] * sqrtw[i]
crossprod(psi_i)
})) / n)
cov_th <- sym_fn(A_inv %*% B_full %*% A_inv)
XtXn <- crossprod(X) / n
XtXn_hc <- crossprod(X * sqrtw) / n
if (is_lm) {
dA_phi <- rbind(
c(-1 / phi^3, numeric(p)),
cbind(numeric(p), -XtXn / phi^2))
dA_dth <- matrix(0, m * m, m); dA_dth[, 1L] <- as.vector(dA_phi)
dB_phi <- rbind(
c((phi^2 - 2 * mean(e^4)) / phi^5,
-(3 / (2 * phi^4)) * colMeans(X) * mean(e^3)),
cbind(matrix(-(3 / (2 * phi^4)) * colMeans(X) * mean(e^3), p, 1),
-XtXn_hc / phi^2))
dB_dth <- matrix(0, m * m, m); dB_dth[, 1L] <- as.vector(dB_phi)
} else {
mu_hat <- fitted(model)
dA_dth <- matrix(0, m * m, m)
for (j in seq_len(m)) {
vj <- w_vec * (1 - 2 * mu_hat) * X[, j]
dA_dth[, j] <- as.vector(crossprod(X, vj * X) / n)
}
dB_dth <- dA_dth
}
mcov_n <- n * sandwich::vcovHC(model, type = type)
nms <- names(coef(model))[coef_idx]
result <- setNames(numeric(length(coef_idx)), nms)
for (ki in seq_along(coef_idx)) {
k <- coef_idx[ki]
if (is_lm) {
L <- matrix(0, 1, m); L[1L, k + 1L] <- 1 # +1: phi occupies position 1
Lm <- matrix(0, 1, p); Lm[1L, k] <- 1
} else {
L <- matrix(0, 1, m); L[1L, k] <- 1
Lm <- L
}
beta_k <- as.numeric(L %*% theta)
cov_beta <- as.numeric(L %*% cov_th %*% t(L))
Ssq <- beta_k^2 / as.numeric(Lm %*% mcov_n %*% t(Lm))
if (!is.finite(Ssq) || Ssq <= 0 || !is.finite(cov_beta) || cov_beta <= 0) {
result[ki] <- NA_real_; next
}
La_row <- L %*% A_inv # 1xm
Lc_row <- L %*% cov_th # 1xm
sc <- beta_k / cov_beta
deriv_th <- sc * L
deriv_A <- (sc^2 / 2) *
(kronecker(La_row, Lc_row) + kronecker(Lc_row, La_row)) %*% dA_dth
deriv_B <- -(sc^2 / 2) * kronecker(La_row, La_row) %*% dB_dth
d_tot <- deriv_th + deriv_A + deriv_B
quad <- max(0, as.numeric(d_tot %*% cov_th %*% t(d_tot)))
result[ki] <- quad / Ssq
}
result
}
# ============================================================
# simCalibrationFigures
# ============================================================
#' Asymptotic Calibration Check for RESI Variance Estimates
#'
#' Runs a plasmode simulation to verify that the asymptotic normal CI machinery
#' is correctly calibrated for all four model settings (lm/glm x
#' parametric/robust). For each (setting, sample size) cell the function
#' checks:
#' \enumerate{
#' \item \strong{Bias(theta)}: \eqn{\hat\theta \to \theta_{\rm true}} --
#' raw coefficients (and \eqn{\phi = \hat\sigma^2} for \code{lm}) converge
#' to the full-dataset values.
#' \item \strong{vcov check A} (estimator consistency):
#' \eqn{n \cdot \bar V_{\rm analytic} / (n_{\rm full} \cdot V_{\rm true}) \to 1}.
#' \item \strong{vcov check B} (calibration):
#' \eqn{n \cdot \widehat{\rm Var}(\hat\beta) / (n_{\rm full} \cdot V_{\rm true}) \to 1}.
#' \item \strong{Bias(R)}: RESI point estimates converge to the full-dataset
#' values.
#' \item \strong{sigma2S check A} (estimator consistency):
#' \eqn{\bar{\hat\sigma}^2_S / \sigma^2_{S,\rm true} \to 1}.
#' \item \strong{sigma2S check B} (CI calibration):
#' \eqn{n \cdot \widehat{\rm Var}(\hat R) / \sigma^2_{S,\rm true} \to 1}.
#' }
#' True values are taken from the full \code{\link{insurance}} dataset using the
#' same definitions as \code{\link{insurancePlasmodeSim}}. \eqn{\sigma^2_S} is
#' extracted from the asymptotic-normal CI half-width:
#' \eqn{\hat\sigma^2_S = n \cdot ({\rm hw}/z_{\alpha/2})^2}.
#'
#' @param nsim Integer, replicates per (setting, \eqn{n}) cell. Default 500.
#' @param n.vec Integer vector of sample sizes.
#' Default \code{c(100, 200, 500, 1000, 2000, 5000)}.
#' @param alpha Numeric, nominal CI level. Default 0.05.
#' @param output.dir Character, directory for raw per-cell RDS files.
#' Default \code{"resiCalibrationSim"}.
#' @param fixed.knots Logical. Fix spline knots at full-dataset quantiles.
#' Default \code{FALSE}.
#' @param mc.cores.reps Integer, cores for within-cell parallelism. Default 1.
#' @param deriv_method Character, type of derivative being evaluated. Package now only includes "extended" in resi implementation.
#'
#' @return Invisibly returns the combined metrics \code{data.frame}.
#' @seealso \code{\link{insurancePlasmodeSim}}, \code{\link{simCompareMethodsFigures}}
#' @importFrom parallel mclapply
#' @importFrom splines ns
#' @importFrom sandwich vcovHC
#' @importFrom stats lm glm vcov binomial qnorm coef setNames sd
#' @importFrom grDevices pdf dev.off
#' @importFrom graphics plot lines points abline legend par axis plot.new layout
#' @export
simCalibrationSim <- function(
nsim = 500L,
n.vec = c(100L, 200L, 500L, 1000L, 2000L, 5000L),
alpha = 0.05,
output.dir = "resiCalibrationSim",
fixed.knots = FALSE,
deriv_method = c("corrected", "original", "population", "population2", "zeroB", "zero_phi_cross", "extended"),
mc.cores.reps = 1L
) {
deriv_method <- match.arg(deriv_method)
raw_dir <- file.path(output.dir, "raw", deriv_method)
dir.create(raw_dir, recursive = TRUE, showWarnings = FALSE)
insurance <- RESI::insurance
n_full <- nrow(insurance)
z_ref <- stats::qnorm(1 - alpha / 2)
ci_lo_col <- paste0(alpha / 2 * 100, "%")
ci_hi_col <- paste0((1 - alpha / 2) * 100, "%")
.fvars <- c("sex", "smoker", "region")
.flevels <- lapply(.fvars, function(v) unique(insurance[[v]]))
names(.flevels) <- .fvars
# ---- Formulas ---------------------------------------------------------------
if (fixed.knots) {
.age_knots <- stats::quantile(insurance$age, c(1/3, 2/3))
.age_bk <- range(insurance$age)
lm_formula <- eval(bquote(log10(charges) ~
splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) *
sex + bmi + smoker + region))
glm_formula <- eval(bquote(I(charges > 15000) ~
splines::ns(age, knots = .(.age_knots), Boundary.knots = .(.age_bk)) *
sex + bmi + smoker + region))
} else {
lm_formula <- log10(charges) ~
splines::ns(age, df = 3) * sex + bmi + smoker + region
glm_formula <- I(charges > 15000) ~
splines::ns(age, df = 3) * sex + bmi + smoker + region
}
model_settings <- list(
list(type = "lm", label = "lm_parametric",
formula = lm_formula, family = NULL,
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "lm", label = "lm_robust",
formula = lm_formula, family = NULL,
vcov_name = "robust", vcovfunc = sandwich::vcovHC),
list(type = "glm", label = "glm_parametric",
formula = glm_formula, family = stats::binomial(),
vcov_name = "parametric", vcovfunc = stats::vcov),
list(type = "glm", label = "glm_robust",
formula = glm_formula, family = stats::binomial(),
vcov_name = "robust", vcovfunc = sandwich::vcovHC)
)
# ---- True values from full dataset ------------------------------------------
message("Computing true values from full dataset (n = ", n_full, ")...")
true_vals <- lapply(model_settings, function(s) {
.formula <- s$formula; .family <- s$family
full_mod <- if (s$type == "lm") {
m <- stats::lm(.formula, data = insurance)
m$call[["formula"]] <- .formula; m
} else {
m <- stats::glm(.formula, data = insurance, family = .family)
m$call[["formula"]] <- .formula
m$call[["family"]] <- .family; m
}
# theta_true: phi = sigma^2 = summary()$sigma^2 (same as .resi_precompute)
phi_true <- if (s$type == "lm") summary(full_mod)$sigma^2 else NULL
beta_true <- stats::coef(full_mod)
# n_full * vcov_true: full covariance matrix (all non-aliased coefficients)
vcov_full_true_raw <- n_full * s$vcovfunc(full_mod)
vcov_diag_true <- diag(vcov_full_true_raw)
# Submatrix for non-intercept terms (computed later once coef_terms_true is known)
# R_true using the same population definition as insurancePlasmodeSim:
# - robust: HC0 (no hat-value correction)
# - parametric lm: divide by sqrt(rdf) = sqrt(n-p), not sqrt(n)
true_vcovfunc_r <- if (s$vcov_name == "robust") {
function(x) sandwich::vcovHC(x, type = "HC0")
} else {
s$vcovfunc
}
rdf_true_r <- if (s$type == "lm" && s$vcov_name == "parametric") {
full_mod$df.residual
} else {
NULL
}
pe_true_raw <- resi_pe(full_mod, data = insurance, vcovfunc = true_vcovfunc_r)
pe_true <- .simDirectRESI(pe_true_raw, n = n_full, rdf = rdf_true_r)
coef_tab_true <- pe_true$coefficients
coef_terms_true <- rownames(coef_tab_true)[rownames(coef_tab_true) != "(Intercept)"]
R_true <- coef_tab_true[coef_terms_true, "RESI"]
names(R_true) <- coef_terms_true # data.frame[rows,col] drops names
# sigma2S_true: Sigma_R[k,k] from the delta-method variance of R_hat,
# evaluated at the population values (full-model coefficients + HC0 Sigma_theta).
# HC0 is used as the "true" population covariance (consistent estimator).
# For parametric: vcov_is_model=TRUE triggers the parametric derivative;
# type="HC0" supplies HC0-based Sigma_theta via the B matrix.
# For robust: vcovfunc=HC0 triggers the robust derivative; type="HC0" same.
# sigma2S_true_k = [dR_dtheta * Sigma_theta_HC0 * dR_dtheta^T]_{kk}
# = n_full * (hw / z_ref)^2 from the normal CI
asym_true <- tryCatch(
resi_pe_asymptotic(full_mod, vcovfunc = true_vcovfunc_r,
ci.method = "normal", type = "HC0",
deriv_method = deriv_method),
error = function(e) NULL
)
if (!is.null(asym_true)) {
tab_a <- asym_true$coefficients
tab_a <- tab_a[rownames(tab_a) != "(Intercept)", , drop = FALSE]
hw_true <- (tab_a[, ci_hi_col] - tab_a[, ci_lo_col]) / 2
names(hw_true) <- rownames(tab_a) # data.frame[,col] drops names
sigma2S_true <- n_full * (hw_true / z_ref)^2
} else {
sigma2S_true <- setNames(rep(NA_real_, length(R_true)), names(R_true))
}
# sigma2S_true_Th1: full delta-method variance (including A/B chain rules)
# evaluated at population values with HC0 Sigma_theta.
# sigma2S_Th1_k = quad_k / S_k^2 where quad_k uses deriv_theta + deriv_A + deriv_B.
coef_idx_th1 <- match(coef_terms_true, names(coef(full_mod)))
sigma2S_true_Th1 <- tryCatch(
.sigma2S_Th1(full_mod, coef_idx_th1, type = "HC0"),
error = function(e) setNames(rep(NA_real_, length(coef_terms_true)),
coef_terms_true)
)
# vcov_phi_phi_true and vcov_phi_beta_true: sandwich (phi,phi) and (phi,beta_k)
# elements for the lm settings, using the per-setting HC type as the "true"
# population reference (so the check targets convergence of the sample
# estimator to the full-dataset sandwich, consistent with vcov_diag_true).
# For GLM there is no phi: set to NA.
precomp_type_tv <- if (s$vcov_name == "robust") "HC3" else "const"
precomp_tv <- NULL # initialise; assigned below for lm only
vcov_phi_phi_true <- NA_real_
vcov_phi_beta_true <- setNames(rep(NA_real_, length(coef_terms_true)),
coef_terms_true)
if (s$type == "lm") {
precomp_tv <- tryCatch(
.resi_precompute(full_mod, type = precomp_type_tv),
error = function(e) NULL
)
if (!is.null(precomp_tv)) {
vcov_phi_phi_true <- precomp_tv$cov_theta[1L, 1L]
beta_names_tv <- names(coef(full_mod))[!is.na(coef(full_mod))]
beta_pos_tv <- match(coef_terms_true, beta_names_tv)
vcov_phi_beta_true <- precomp_tv$cov_theta[1L, 1L + beta_pos_tv]
names(vcov_phi_beta_true) <- coef_terms_true
}
} else {
# GLM: precomp_tv needed for vcov_full_true_mat (beta-beta block)
precomp_tv <- tryCatch(
.resi_precompute(full_mod, type = precomp_type_tv),
error = function(e) NULL
)
}
# Chain decomposition of Sigma_R at true-value estimates (HC0 sandwich).
# For 'extended': Sigma_R comes from influence-function approach; no chain decomp.
# For other methods: standard J = J_direct + J_Achain + J_Bchain decomposition.
precomp_hc0 <- if (deriv_method == "extended") {
tryCatch(.resi_precompute_ext(full_mod, type = "HC0"), error = function(e) NULL)
} else {
tryCatch(.resi_precompute(full_mod, type = "HC0", deriv_method = deriv_method),
error = function(e) NULL)
}
na_terms <- setNames(rep(NA_real_, length(coef_terms_true)), coef_terms_true)
J_dir_list <- list()
J_Achain_list <- list()
J_Bchain_list <- list()
sig2S_true_dir <- na_terms
sig2S_true_Achain <- na_terms
sig2S_true_Bchain <- na_terms
theta_true_full <- NULL
if (!is.null(precomp_hc0)) {
Sig_hc0 <- precomp_hc0$cov_theta # m x m Sigma_theta at HC0
theta_true_full <- precomp_hc0$theta_hat # (phi, beta_1,...,beta_p)
for (tm in coef_terms_true) {
L_tm <- .get_L_coef(full_mod, tm)
if (deriv_method == "extended") {
# Extended: Sigma_R is the total; store in dir, Achain/Bchain = 0
ct_tm <- tryCatch(.resi_contrast_ext(precomp_hc0, L_tm), error = function(e) NULL)
if (!is.null(ct_tm)) {
J_dir_list[[tm]] <- matrix(0, 1, precomp_hc0$m)
J_Achain_list[[tm]] <- matrix(0, 1, precomp_hc0$m)
J_Bchain_list[[tm]] <- matrix(0, 1, precomp_hc0$m)
sig2S_true_dir[tm] <- as.numeric(ct_tm$Sigma_R)
sig2S_true_Achain[tm] <- 0
sig2S_true_Bchain[tm] <- 0
}
} else {
ct_tm <- tryCatch(.resi_contrast(precomp_hc0, L_tm), error = function(e) NULL)
if (!is.null(ct_tm)) {
J_dir_list[[tm]] <- ct_tm$dR_direct
J_Achain_list[[tm]] <- ct_tm$dR_Achain
J_Bchain_list[[tm]] <- ct_tm$dR_Bchain
sig2S_true_dir[tm] <- as.numeric(ct_tm$dR_direct %*% Sig_hc0 %*% t(ct_tm$dR_direct))
sig2S_true_Achain[tm] <- as.numeric(ct_tm$dR_Achain %*% Sig_hc0 %*% t(ct_tm$dR_Achain))
sig2S_true_Bchain[tm] <- as.numeric(ct_tm$dR_Bchain %*% Sig_hc0 %*% t(ct_tm$dR_Bchain))
}
}
}
}
list(deriv_method_used = deriv_method,
phi_true = phi_true,
beta_true = beta_true,
vcov_diag_true = vcov_diag_true,
# vcov_full_true_mat: full analytic Sigma_theta from precomp_tv (HC3 or const).
# For lm: (p+1)x(p+1) with phi as first row/col; for glm: pxp.
# Row/col names: c("phi", coef_terms_true) for lm, coef_terms_true for glm.
vcov_full_true_mat = if (!is.null(precomp_tv)) {
if (s$type == "lm") {
m_all <- precomp_tv$m
beta_names_all <- names(coef(full_mod))[!is.na(coef(full_mod))]
beta_pos_all <- match(coef_terms_true, beta_names_all)
idx_all <- c(1L, 1L + beta_pos_all)
nm_all <- c("phi", coef_terms_true)
mat_all <- precomp_tv$cov_theta[idx_all, idx_all, drop = FALSE]
rownames(mat_all) <- colnames(mat_all) <- nm_all
mat_all
} else {
beta_names_all <- names(coef(full_mod))[!is.na(coef(full_mod))]
beta_pos_all <- match(coef_terms_true, beta_names_all)
mat_all <- precomp_tv$cov_theta[beta_pos_all, beta_pos_all, drop = FALSE]
rownames(mat_all) <- colnames(mat_all) <- coef_terms_true
mat_all
}
} else NULL,
vcov_phi_phi_true = vcov_phi_phi_true,
vcov_phi_beta_true = vcov_phi_beta_true,
R_true = R_true,
sigma2S_true = sigma2S_true,
sigma2S_true_Th1 = sigma2S_true_Th1,
J_dir_list = J_dir_list,
J_Achain_list = J_Achain_list,
J_Bchain_list = J_Bchain_list,
sig2S_true_dir = sig2S_true_dir,
sig2S_true_Achain = sig2S_true_Achain,
sig2S_true_Bchain = sig2S_true_Bchain,
theta_true_full = theta_true_full,
coef_terms = coef_terms_true)
})
names(true_vals) <- sapply(model_settings, `[[`, "label")
# ---- Per-setting, per-n simulations -----------------------------------------
all_metrics <- list()
for (s in model_settings) {
lbl <- s$label
tv <- true_vals[[lbl]]
is_lm <- (s$type == "lm")
.formula <- s$formula; .family <- s$family
coef_terms <- tv$coef_terms
message("\nSetting: ", lbl)
for (n_s in n.vec) {
cell_label <- paste0(lbl, "_n", n_s)
rds_path <- file.path(raw_dir, paste0(cell_label, ".rds"))
if (file.exists(rds_path)) {
cell_data <- readRDS(rds_path)
if (cell_data$n_success > 0L) {
message(" n = ", n_s, " (loading cached)")
} else {
message(" n = ", n_s, " (cached n_success = 0; re-running)")
file.remove(rds_path)
cell_data <- NULL
}
} else {
cell_data <- NULL
}
if (is.null(cell_data)) {
message(" n = ", n_s, " ...")
reps_raw <- parallel::mclapply(seq_len(nsim), function(i) {
repeat {
dat <- insurance[sample(n_full, n_s, replace = TRUE), ]
ok <- all(vapply(.fvars, function(v)
all(.flevels[[v]] %in% dat[[v]]), logical(1L)))
if (ok && !is_lm) {
biny <- as.integer(dat$charges > 15000)
ok <- length(unique(biny)) > 1L &&
all(tapply(biny, dat$smoker,
function(x) length(unique(x)) > 1L))
}
if (ok) break
}
mod <- tryCatch({
m <- if (is_lm) stats::lm(.formula, data = dat)
else stats::glm(.formula, data = dat, family = .family)
m$call[["formula"]] <- .formula
if (!is_lm) m$call[["family"]] <- .family
m
}, error = function(e) NULL)
if (is.null(mod)) return(NULL)
tryCatch({
# theta_hat
phi_hat <- if (is_lm) summary(mod)$sigma^2 else NULL
beta_hat <- stats::coef(mod)[coef_terms]
# n_s * diag(vcov_hat) for non-intercept terms
vcov_hat_diag <- n_s * diag(s$vcovfunc(mod))[coef_terms]
# R_hat and sigma2S_hat via asymptotic normal CI
asym <- resi_pe_asymptotic(mod, vcovfunc = s$vcovfunc,
ci.method = "normal",
deriv_method = deriv_method)
tab <- asym$coefficients
tab <- tab[rownames(tab) != "(Intercept)", , drop = FALSE]
hw <- (tab[coef_terms, ci_hi_col] - tab[coef_terms, ci_lo_col]) / 2
names(hw) <- coef_terms # data.frame[rows,col] drops names
R_hat <- tab[coef_terms, "RESI"]
names(R_hat) <- coef_terms
sigma2S_hat <- n_s * (hw / z_ref)^2
names(sigma2S_hat) <- coef_terms
# phi-variance and phi-beta covariances from the full sandwich
# (lm only; requires model-level precomputation)
vcov_phi_phi <- NULL
vcov_phi_beta <- NULL
if (is_lm) {
precomp_type_rep <- if (s$vcov_name == "robust") "HC3" else "const"
precomp_rep <- tryCatch(
.resi_precompute(mod, type = precomp_type_rep,
deriv_method = deriv_method),
error = function(e) NULL
)
if (!is.null(precomp_rep)) {
# cov_theta[1,1] = Sigma_theta_{phi,phi} = asymptotic variance of
# sqrt(n)(phi_hat - phi). Do NOT multiply by n_s: cov_theta is
# already the asymptotic (scaled) variance, analogous to
# n_s * diag(vcovfunc(mod)) for the beta terms.
vcov_phi_phi <- precomp_rep$cov_theta[1L, 1L]
beta_names_rep <- names(coef(mod))[!is.na(coef(mod))]
beta_pos_rep <- match(coef_terms, beta_names_rep)
vcov_phi_beta <- precomp_rep$cov_theta[1L, 1L + beta_pos_rep]
names(vcov_phi_beta) <- coef_terms
}
}
list(phi_hat = phi_hat,
beta_hat = beta_hat,
theta_hat_full = NULL, # removed: Jacobian-projection MC not used
vcov_hat_diag = vcov_hat_diag,
R_hat = R_hat,
sigma2S_hat = sigma2S_hat,
vcov_phi_phi = vcov_phi_phi,
vcov_phi_beta = vcov_phi_beta)
}, error = function(e) NULL)
}, mc.cores = mc.cores.reps)
reps <- Filter(Negate(is.null), reps_raw)
# Store matrices
cell_data <- list(
n = n_s,
n_success = length(reps),
phi_vec = if (is_lm) sapply(reps, `[[`, "phi_hat") else NULL,
beta_mat = do.call(rbind, lapply(reps, `[[`, "beta_hat")),
vcov_mat = do.call(rbind, lapply(reps, `[[`, "vcov_hat_diag")),
R_mat = do.call(rbind, lapply(reps, `[[`, "R_hat")),
sig2S_mat = do.call(rbind, lapply(reps, `[[`, "sigma2S_hat")),
phi_var_vec = if (is_lm && !is.null(reps[[1L]]$vcov_phi_phi))
sapply(reps, `[[`, "vcov_phi_phi") else NULL,
phi_cov_mat = if (is_lm && !is.null(reps[[1L]]$vcov_phi_beta))
do.call(rbind, lapply(reps, `[[`, "vcov_phi_beta")) else NULL
)
saveRDS(cell_data, rds_path)
}
if (cell_data$n_success == 0L) next
# ---- Compute metrics ---------------------------------------------------
n_s_eff <- cell_data$n # actual n used
# (1) Bias(phi) for lm
phi_bias <- if (!is.null(cell_data$phi_vec))
mean(cell_data$phi_vec, na.rm = TRUE) - tv$phi_true else NA_real_
# (2) Bias(beta_k)
beta_bias <- colMeans(cell_data$beta_mat, na.rm = TRUE) -
tv$beta_true[coef_terms]
# (3) vcov check A: mean(n_s * vcov_hat_kk) / (n_full * vcov_true_kk)
vcov_A <- colMeans(cell_data$vcov_mat, na.rm = TRUE) /
tv$vcov_diag_true[coef_terms]
# (4) vcov check B: n_s * Var_empirical(beta_k) / (n_full * vcov_true_kk)
vcov_B <- (n_s_eff * apply(cell_data$beta_mat, 2L, stats::var, na.rm = TRUE)) /
tv$vcov_diag_true[coef_terms]
# (5) Bias(R_k)
R_bias <- colMeans(cell_data$R_mat, na.rm = TRUE) - tv$R_true[coef_terms]
# (6) sigma2S check A: mean(sigma2S_hat_k) / sigma2S_true_k
sig2S_A <- colMeans(cell_data$sig2S_mat, na.rm = TRUE) /
tv$sigma2S_true[coef_terms]
# (7) sigma2S check B: n_s * Var_empirical(R_k) / sigma2S_true_k
sig2S_B <- (n_s_eff * apply(cell_data$R_mat, 2L, stats::var, na.rm = TRUE)) /
tv$sigma2S_true[coef_terms]
# (8) Coefficient of variation of the variance estimator V_hat_kk across
# replicates -- quantifies the "variance of the variance estimator".
cv_Vhat <- apply(cell_data$vcov_mat, 2L, function(x)
stats::sd(x, na.rm = TRUE) / mean(x, na.rm = TRUE))
# (9) phi variance check A: mean(Sigma_theta_phi_phi_hat) / Sigma_theta_phi_phi_true
# cov_theta[1,1] IS Sigma_theta (asymptotic variance of sqrt(n)*phi_hat),
# so no n factor is needed -- analogous to n_s*diag(vcovfunc) for beta.
# (scalar, same for all terms in this cell; NA for GLM)
phi_var_A <- if (!is.null(cell_data$phi_var_vec) &&
is.finite(tv$vcov_phi_phi_true) &&
tv$vcov_phi_phi_true > 0)
mean(cell_data$phi_var_vec, na.rm = TRUE) / tv$vcov_phi_phi_true
else NA_real_
# (10) phi variance check B: n_s * Var_empirical(phi_hat) / Sigma_theta_phi_phi_true
# Empirical counterpart: n_s * var(phi_vec) estimates Sigma_theta_{phi,phi}.
phi_var_B <- if (!is.null(cell_data$phi_vec) &&
is.finite(tv$vcov_phi_phi_true) &&
tv$vcov_phi_phi_true > 0)
n_s_eff * stats::var(cell_data$phi_vec, na.rm = TRUE) / tv$vcov_phi_phi_true
else NA_real_
# (11) phi-beta covariance check A: per-term ratio (NA for GLM or near-zero truth)
phi_cov_A <- if (!is.null(cell_data$phi_cov_mat) &&
all(is.finite(tv$vcov_phi_beta_true)))
colMeans(cell_data$phi_cov_mat, na.rm = TRUE) / tv$vcov_phi_beta_true
else setNames(rep(NA_real_, length(coef_terms)), coef_terms)
# (12) Raw MC variances of the estimator components (no Jacobian involved).
# var_R_MC_k = n_s * var(R_hat_k) -- actual MC variance of RESI
# var_b_MC_k = n_s * var(beta_hat_k) -- MC variance of numerator
# var_se_MC_k = n_s * var(vcov_mat_k) -- MC variance of scaled sandwich vcov
# Compare var_R_MC to sigma2S_true (analytic total) to get the total ratio.
# Compare var_b_MC to vcov_diag_true to get the direct-channel ratio (= vcov_B).
# If sig2S_B >> vcov_B, the A/B chain or nonlinear terms are responsible.
var_R_MC <- n_s_eff * apply(cell_data$R_mat, 2L, stats::var, na.rm = TRUE)
var_b_MC <- n_s_eff * apply(cell_data$beta_mat, 2L, stats::var, na.rm = TRUE)
var_se_MC <- n_s_eff * apply(cell_data$vcov_mat, 2L, stats::var, na.rm = TRUE)
row <- data.frame(
model = s$type,
vcov = s$vcov_name,
deriv_method = deriv_method,
n = n_s_eff,
n_success = cell_data$n_success,
term = coef_terms,
phi_bias = phi_bias,
beta_bias = beta_bias,
vcov_A = vcov_A,
vcov_B = vcov_B,
R_bias = R_bias,
sig2S_A = sig2S_A,
sig2S_B = sig2S_B,
cv_Vhat = cv_Vhat,
phi_var_A = phi_var_A,
phi_var_B = phi_var_B,
phi_cov_A = phi_cov_A,
var_R_MC = var_R_MC,
var_b_MC = var_b_MC,
var_se_MC = var_se_MC,
row.names = NULL,
stringsAsFactors = FALSE
)
all_metrics[[length(all_metrics) + 1L]] <- row
}
}
summary_df <- do.call(rbind, all_metrics)
saveRDS(summary_df, file.path(output.dir, paste0("calibration_summary_", deriv_method, ".rds")))
saveRDS(true_vals, file.path(output.dir, paste0("calibration_truevals_", deriv_method, ".rds")))
message("Simulation complete (deriv_method = '", deriv_method, "'). Results saved to: ", output.dir)
invisible(summary_df)
}
#' Calibration Figures for RESI Variance Estimates
#'
#' Reads output from \code{\link{simCalibrationSim}} and produces one PDF per
#' model setting showing convergence of point estimates, covariance estimates,
#' RESI estimates, and RESI variance estimates to their population targets.
#'
#' @param output.dir Character, directory containing output from
#' \code{\link{simCalibrationSim}}. Default \code{"resiCalibrationSim"}.
#' @param figures.dir Character, output directory for PDFs.
#' Default \code{file.path(output.dir, "figures")}.
#' @param alpha Numeric, nominal CI level. Default 0.05.
#' @param deriv_method Character, type of derivative being evaluated. Package now only includes "extended" in resi implementation.
#'
#' @return Invisibly returns the summary data frame. Saves PDF figures to
#' \code{figures.dir}.
#' @seealso \code{\link{simCalibrationSim}}, \code{\link{insurancePlasmodeSim}}
#' @importFrom splines ns
#' @importFrom sandwich vcovHC
#' @importFrom stats lm glm vcov binomial setNames
#' @importFrom grDevices pdf dev.off
#' @importFrom graphics plot lines points abline legend par axis plot.new layout
#' @export
simCalibrationFigures <- function(
output.dir = "resiCalibrationSim",
figures.dir = NULL,
alpha = 0.05,
deriv_method = c("extended", "corrected", "original", "population", "population2", "zeroB", "zero_phi_cross")
) {
deriv_method <- match.arg(deriv_method)
if (is.null(figures.dir))
figures.dir <- file.path(output.dir, "figures", deriv_method)
dir.create(figures.dir, recursive = TRUE, showWarnings = FALSE)
summary_path <- file.path(output.dir, paste0("calibration_summary_", deriv_method, ".rds"))
truevals_path <- file.path(output.dir, paste0("calibration_truevals_", deriv_method, ".rds"))
if (!file.exists(summary_path))
stop("'", summary_path, "' not found. Run simCalibrationSim(deriv_method='", deriv_method, "') first.")
if (!file.exists(truevals_path))
stop("'", truevals_path, "' not found. Run simCalibrationSim(deriv_method='", deriv_method, "') first.")
summary_df <- readRDS(summary_path)
true_vals <- readRDS(truevals_path)
# Reconstruct minimal model_settings from true_vals names (label -> type/vcov_name)
model_settings <- lapply(names(true_vals), function(lbl) {
parts <- strsplit(lbl, "_", fixed = TRUE)[[1L]]
list(type = parts[1L],
label = lbl,
vcov_name = parts[2L])
})
# ---- Figures ---------------------------------------------------------------
# Abbreviate long term names and format for display in legends
.abbrev <- function(x) {
x <- sub("splines::ns\\(age, df = [0-9]+\\)", "ns(age)", x)
x <- sub("regionnorthwest", "Reg:NW", x)
x <- sub("regionsoutheast", "Reg:SE", x)
x <- sub("regionsouthwest", "Reg:SW", x)
x <- sub("regionnortheast", "Reg:NE", x)
x <- sub(":sexmale$", ":sex", x)
x
}
# Pretty-print setting label for titles (e.g. "lm_robust" -> "Robust LM")
.fmt_lbl <- function(lbl) {
parts <- strsplit(lbl, "_", fixed = TRUE)[[1L]]
mod <- toupper(parts[1L])
vcov <- if (length(parts) > 1L && parts[2L] == "robust") "Robust" else "Parametric"
paste(vcov, mod)
}
.sim_pal <- c("#1F77B4", "#D62728", "#2CA02C", "#FF7F0E", "#9467BD",
"#8C564B", "#E377C2", "#17BECF", "#BCBD22", "#7F7F7F",
"#AEC7E8", "#98DF8A")
n_vals_plot <- sort(unique(summary_df$n))
for (s in model_settings) {
lbl <- s$label
is_lm <- (s$type == "lm")
tv <- true_vals[[lbl]]
coef_terms <- tv$coef_terms
n_terms <- length(coef_terms)
term_cols <- setNames(.sim_pal[seq_len(n_terms)], coef_terms)
sub <- summary_df[summary_df$model == s$type &
summary_df$vcov == s$vcov_name, ]
if (nrow(sub) == 0L) next
fig_path <- file.path(figures.dir, paste0("calibration_", lbl, ".pdf"))
# Layout: [bias_theta | vcov_ratios] / [bias_R | sig2S_ratios] / [legend]
# Panel 1: Bias of theta (phi + beta_k lines; phi as a special dotted line)
# Panel 2: vcov ratios (solid=A, dashed=B, one color per term)
# Panel 3: Bias of R_k
# Panel 4: sigma2S ratios (solid=A, dashed=B, dotted=Th1)
# Panel 5: legend (spans full width)
is_glm <- !is_lm
grDevices::pdf(fig_path, width = 9, height = 8.5)
graphics::layout(
matrix(c(1, 2, 3, 4, 5, 5), nrow = 3L, byrow = TRUE),
widths = c(4.0, 4.0),
heights = c(3.0, 3.0, 2.0)
)
# --- Panel 1: Bias(theta) ---
# Collect bias range for beta + phi
all_bias_theta <- unlist(lapply(coef_terms, function(tm) {
sub[sub$term == tm, "beta_bias"]
}))
if (is_lm) {
phi_biases <- sub[sub$term == coef_terms[1L], "phi_bias"]
all_bias_theta <- c(all_bias_theta, phi_biases)
}
ylim1 <- range(c(all_bias_theta, 0), na.rm = TRUE)
ylim1 <- ylim1 + diff(ylim1) * c(-0.06, 0.06)
if (is_glm) ylim1 <- c(-0.2, 0.2) # clip glm bias panel
graphics::par(mar = c(3.2, 3.5, 2.2, 0.5), mgp = c(2.0, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals_plot), ylim = ylim1,
xlab = "n",
ylab = expression(paste("Calibration of ", theta)),
main = bquote(.(paste0(.fmt_lbl(lbl), ": Calibration of ")) * theta),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1L, at = n_vals_plot, labels = n_vals_plot, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 0, lty = 2L, col = "gray40")
for (tm in coef_terms) {
d <- sub[sub$term == tm, ]
d <- d[order(d$n), ]
graphics::lines(d$n, d$beta_bias, col = term_cols[[tm]], lwd = 1.5)
graphics::points(d$n, d$beta_bias, col = term_cols[[tm]], pch = 16L, cex = 0.7)
}
if (is_lm) {
d <- sub[sub$term == coef_terms[1L], ]
d <- d[order(d$n), ]
graphics::lines(d$n, d$phi_bias, col = "#000000", lwd = 2L, lty = 3L)
graphics::points(d$n, d$phi_bias, col = "#000000", pch = 17L, cex = 0.8)
}
# --- Panel 2: normalized-bias covariance calibration ---
# For each n, load raw cell, build full theta matrix (phi + beta for lm),
# and compute the normalized bias of every covariance element:
# diff[j,k] = (Sigma_analytic[j,k] - n_s*cov_MC[j,k]) /
# sqrt(n_s*var_MC[j,j] * n_s*var_MC[k,k])
# = (Sigma_A[j,k] - Sigma_MC[j,k]) / sqrt(Sigma_MC[j,j] * Sigma_MC[k,k])
# Diagonal: diff[k,k] = Sigma_A[k,k]/Sigma_MC[k,k] - 1 = 1/vcov_B_k - 1
# Off-diagonal: normalized difference in covariance (target 0)
# Includes phi row/col for lm; colored lines = diagonal, gray = off-diagonal.
raw_dir_p <- file.path(output.dir, "raw", deriv_method)
Sig_A_mat <- tv$vcov_full_true_mat # analytic Sigma_theta (includes phi if lm)
lm_has_phi <- is_lm && !is.null(Sig_A_mat) && "phi" %in% rownames(Sig_A_mat)
all_labels <- if (!is.null(Sig_A_mat)) rownames(Sig_A_mat) else coef_terms
n_all <- length(all_labels)
diag_diff_list <- vector("list", length(n_vals_plot))
off_diff_list <- vector("list", length(n_vals_plot))
for (i_n in seq_along(n_vals_plot)) {
nv <- n_vals_plot[i_n]
rp <- file.path(raw_dir_p, paste0(lbl, "_n", nv, ".rds"))
if (!file.exists(rp)) next
cd <- readRDS(rp)
if (is.null(cd$beta_mat) || nrow(cd$beta_mat) < 3L) next
n_s_p <- cd$n
bm <- cd$beta_mat
if (!all(coef_terms %in% colnames(bm))) next
bm <- bm[, coef_terms, drop = FALSE]
# Full theta matrix: prepend phi for lm
theta_mc <- if (lm_has_phi && !is.null(cd$phi_vec))
cbind(phi = cd$phi_vec, bm) else bm
if (is.null(Sig_A_mat)) next
Sig_MC <- n_s_p * stats::cov(theta_mc, use = "pairwise.complete.obs")
# Normalized bias: (Sig_A - Sig_MC) / sqrt(Sig_MC_jj * Sig_MC_kk)
sd_MC <- sqrt(pmax(diag(Sig_MC), .Machine$double.eps))
Sig_MC_nm <- Sig_A_mat # same dimension; use A for name alignment
if (nrow(Sig_MC) == nrow(Sig_A_mat)) {
diff_mat <- (Sig_A_mat - Sig_MC) /
outer(sd_MC, sd_MC) # element-wise divide by product of MC SDs
} else {
diff_mat <- matrix(NA_real_, nrow(Sig_A_mat), ncol(Sig_A_mat))
}
rownames(diff_mat) <- colnames(diff_mat) <- all_labels
diag_diff_list[[i_n]] <- diag(diff_mat)
off_diff_list[[i_n]] <- diff_mat[upper.tri(diff_mat)]
}
diag_diff_mat <- do.call(rbind, lapply(diag_diff_list, function(x)
if (is.null(x)) rep(NA_real_, n_all) else x[all_labels]))
off_diff_vals <- unlist(off_diff_list)
all_diff_vals <- c(as.vector(diag_diff_mat), off_diff_vals)
ylim2 <- range(c(all_diff_vals, 0), na.rm = TRUE)
ylim2 <- ylim2 + diff(ylim2) * c(-0.06, 0.06)
if (!is.finite(ylim2[1L])) ylim2 <- c(-1, 2)
if (is_glm) ylim2[2L] <- min(ylim2[2L], 3)
graphics::par(mar = c(3.2, 3.5, 2.2, 0.5), mgp = c(2.0, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals_plot), ylim = ylim2,
xlab = "n",
ylab = expression(paste("(", Sigma[A] - Sigma[MC], ") / ",
sqrt(Sigma[MC*","*jj] %*% Sigma[MC*","*kk]))),
main = paste0(.fmt_lbl(lbl), ": Covariance calibration"),
log = "x", xaxt = "n", bty = "l", font.main = 1L)
graphics::axis(1L, at = n_vals_plot, labels = n_vals_plot, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 0, lty = 2L, col = "gray40")
# Off-diagonal: thin gray
n_pairs <- if (n_all > 1L) ncol(utils::combn(n_all, 2L)) else 0L
for (pair in seq_len(n_pairs)) {
vals <- vapply(off_diff_list, function(x)
if (is.null(x) || length(x) < pair) NA_real_ else x[pair], numeric(1L))
if (any(is.finite(vals)))
graphics::lines(n_vals_plot, vals, col = "gray70", lwd = 0.8)
}
# Diagonal: colored per term (beta), black for phi
for (k_idx in seq_len(n_all)) {
lbl_k <- all_labels[k_idx]
vals <- diag_diff_mat[, k_idx]
if (!any(is.finite(vals))) next
if (lbl_k == "phi") {
graphics::lines(n_vals_plot, vals, col = "#000000", lwd = 2L)
graphics::points(n_vals_plot, vals, col = "#000000", pch = 15L, cex = 0.8)
} else if (lbl_k %in% names(term_cols)) {
graphics::lines(n_vals_plot, vals, col = term_cols[[lbl_k]], lwd = 1.5)
graphics::points(n_vals_plot, vals, col = term_cols[[lbl_k]], pch = 16L, cex = 0.7)
}
}
# --- Panel 3: Bias(R) ---
all_R_bias <- unlist(lapply(coef_terms, function(tm) sub[sub$term == tm, "R_bias"]))
ylim3 <- range(c(all_R_bias, 0), na.rm = TRUE)
ylim3 <- ylim3 + diff(ylim3) * c(-0.06, 0.06)
if (is_glm) ylim3 <- c(-0.1, 0.1) # clip glm bias panel
graphics::par(mar = c(3.2, 3.5, 2.2, 0.5), mgp = c(2.0, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals_plot), ylim = ylim3,
xlab = "n",
ylab = expression(paste("Calibration of ", R)),
main = bquote(.(paste0(.fmt_lbl(lbl), ": Calibration of ")) * R),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1L, at = n_vals_plot, labels = n_vals_plot, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 0, lty = 2L, col = "gray40")
for (tm in coef_terms) {
d <- sub[sub$term == tm, ]
d <- d[order(d$n), ]
graphics::lines(d$n, d$R_bias, col = term_cols[[tm]], lwd = 1.5)
graphics::points(d$n, d$R_bias, col = term_cols[[tm]], pch = 16L, cex = 0.7)
}
# --- Panel 4: sigma2S ratios (true-to-MC solid, init-to-MC dashed) ---
# Compute ylim including BOTH sig2S_B and sig2S_Th1_vals
all_sig2S <- unlist(lapply(coef_terms, function(tm) {
d <- sub[sub$term == tm, ]
r_Th1 <- tv$sigma2S_true[tm] / tv$sigma2S_true_Th1[tm]
sig2S_Th1_v <- d$sig2S_B * r_Th1
c(d$sig2S_B, sig2S_Th1_v)
}))
ylim4 <- range(c(1/all_sig2S, 1), na.rm = TRUE)
ylim4 <- ylim4 + diff(ylim4) * c(-0.06, 0.10)
if (is_glm) ylim4[2L] <- max(ylim4[2L], 3)
graphics::par(mar = c(3.2, 3.5, 2.2, 0.5), mgp = c(2.0, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals_plot), ylim = ylim4,
xlab = "n",
ylab = expression(paste(sigma[R]^2, " ratio")),
main = bquote(.(paste0(.fmt_lbl(lbl), ": ")) *
sigma[R]^2 ~ "calibration"),
log = "x", xaxt = "n", bty = "l")
graphics::axis(1L, at = n_vals_plot, labels = n_vals_plot, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 1, lty = 2L, col = "gray40")
for (tm in coef_terms) {
d <- sub[sub$term == tm, ]
d <- d[order(d$n), ]
# sig2S_Th1 = MC / sigma2S_true_Th1 = (sig2S_B * sigma2S_true) / sigma2S_true_Th1
r_Th1 <- tv$sigma2S_true[tm] / tv$sigma2S_true_Th1[tm]
sig2S_Th1_vals <- 1/(d$sig2S_B * r_Th1)
# Solid: sig2S_B = sigma2S_true /sigma2S_MC
# Dashed: sig2S_Th1 = sigma2S_true_init/sigma2S_MC ("init-to-MC" ratio)
graphics::lines(d$n, 1/d$sig2S_B, col = term_cols[[tm]], lwd = 1.8, lty = 1L)
graphics::lines(d$n, sig2S_Th1_vals, col = term_cols[[tm]], lwd = 1.5, lty = 2L)
graphics::points(d$n, 1/d$sig2S_B, col = term_cols[[tm]], pch = 16L, cex = 0.7)
graphics::points(d$n, sig2S_Th1_vals, col = term_cols[[tm]], pch = 1L, cex = 0.7)
}
# --- Panel 5: Legend ---
short_terms <- .abbrev(coef_terms)
leg_ncol <- ceiling(n_terms / 3L)
graphics::par(mar = c(0.2, 0.5, 0.2, 0.5), cex = 0.90)
graphics::plot.new()
# Left: coefficient colour key (coefficients only, without phi)
graphics::legend(x = 0.01, y = 1.0,
legend = short_terms,
col = unname(term_cols[coef_terms]),
pch = rep(16L, n_terms),
lty = rep(1L, n_terms),
lwd = 1.5,
bty = "n", cex = 0.85, xjust = 0L, yjust = 1L,
ncol = leg_ncol,
title = "Coefficient (all panels)", title.font = 2L)
# If lm: phi below coefficients
if (is_lm) {
graphics::legend(x = 0.01, y = 0.65,
legend = expression(phi ~ "(error variance)"),
col = "#000000", pch = 15L, lty = 1L, lwd = 1.5,
bty = "n", cex = 0.85, xjust = 0L, yjust = 1L)
}
# Right: line-style key
graphics::legend(x = 0.60, y = 1.0,
legend = c(
"Diagonal: vcov ratio - 1",
"Off-diagonal: norm. bias (gray)",
expression(sigma[R]^2 ~ ": MC / true (solid)"),
expression(sigma[R]^2 ~ ": MC / init (dashed)")
),
col = c("black", "gray70", "gray30", "gray30"),
lty = c(1L, 1L, 1L, 2L),
pch = c(15L, NA_integer_, 16L, 1L),
lwd = c(1.5, 0.8, 1.8, 1.5),
bty = "n", cex = 0.85, xjust = 0L, yjust = 1L,
title = "Line style key", title.font = 2L)
grDevices::dev.off()
message("Saved: ", fig_path)
}
message("Calibration figures saved to: ", figures.dir)
# ---- Structured return value -----------------------------------------------
# For each setting: matrices of size (n_vals x terms) for each metric,
# plus a phi_bias vector (length n_vals) for lm settings.
structured <- lapply(model_settings, function(s) {
lbl <- s$label
sub <- summary_df[summary_df$model == s$type &
summary_df$vcov == s$vcov_name, ]
if (nrow(sub) == 0L) return(NULL)
tv <- true_vals[[lbl]]
terms_here <- tv$coef_terms
n_here <- sort(unique(sub$n))
# Helper: reshape one metric column into a (n_vals x terms) matrix
.mat <- function(col) {
m <- matrix(NA_real_, nrow = length(n_here), ncol = length(terms_here),
dimnames = list(n = as.character(n_here), term = terms_here))
for (ni in seq_along(n_here)) {
for (tm in terms_here) {
v <- sub[sub$n == n_here[ni] & sub$term == tm, col]
if (length(v) == 1L) m[ni, tm] <- v
}
}
m
}
out <- list(
n = n_here,
n_success = setNames(
vapply(n_here, function(ni) {
v <- sub[sub$n == ni & sub$term == terms_here[1L], "n_success"]
if (length(v)) v[1L] else NA_integer_
}, integer(1L)), as.character(n_here)),
# (1) Bias of raw coefficients
beta_bias = .mat("beta_bias"),
# (2) vcov: analytical estimator ratio
vcov_A = .mat("vcov_A"),
# (3) vcov: empirical-variance calibration ratio
vcov_B = .mat("vcov_B"),
# (4) Bias of R
R_bias = .mat("R_bias"),
# (5) sigma2S: analytical estimator ratio
sig2S_A = .mat("sig2S_A"),
# (6) sigma2S: empirical-variance calibration ratio (key CI check)
sig2S_B = .mat("sig2S_B"),
# (7) sigma2S: empirical variance / Th1 full-delta-method reference
sig2S_Th1 = local({
r <- tv$sigma2S_true[terms_here] / tv$sigma2S_true_Th1[terms_here]
m <- .mat("sig2S_B")
sweep(m, 2L, r, "*")
}),
# Reference values used for normalisation
R_true = tv$R_true,
sigma2S_true = tv$sigma2S_true,
sigma2S_true_Th1 = tv$sigma2S_true_Th1,
vcov_diag_true = tv$vcov_diag_true[terms_here]
)
if (s$type == "lm") {
phi_row <- sub[sub$term == terms_here[1L], ]
phi_row <- phi_row[order(phi_row$n), ]
out$phi_bias <- setNames(phi_row$phi_bias, as.character(phi_row$n))
out$phi_true <- tv$phi_true
}
out
})
names(structured) <- sapply(model_settings, `[[`, "label")
structured <- Filter(Negate(is.null), structured)
invisible(list(summary_table = summary_df, by_setting = structured))
}
#' Per-Term CI Method Comparison Figures
#'
#' Reads simulation summary tables from multiple output directories (one per CI
#' method) and produces PDF figures comparing CI methods across sample sizes.
#' For each (model type x variance estimator x table) combination two PDFs are
#' saved: an estimator comparison (Bias, MSE) and a CI comparison (SE
#' calibration, coverage, width).
#'
#' @param output.dirs Named character vector mapping CI method labels to their
#' simulation output directories. Default:
#' \code{c(boot = "resiBootSim", normal = "resiAsympNormalSim",
#' qf = "resiAsympQFSim", cf = "resiAsympCFSim")}.
#' Directories that do not exist are silently skipped.
#' @param figures.dir Character, directory where comparison figures are saved.
#' Default: \code{file.path(output.dirs[1], "figures", "method_comparison")}.
#' @param alpha Numeric, nominal CI level used for coverage reference lines.
#' Default 0.05.
#' @param fixed.knots Logical. Must match the value used in the original
#' \code{\link{insurancePlasmodeSim}} call. Default \code{FALSE}.
#'
#' @return Invisibly returns the combined summary \code{data.frame}. Saves
#' \code{estimator_<model>_<vcov>_<table>.pdf},
#' \code{compare_<model>_<vcov>_<table>.pdf}, and
#' \code{covquant_<model>_<vcov>_<table>.pdf} to \code{figures.dir}.
#' @seealso \code{\link{insurancePlasmodeSim}}, \code{\link{simFigures}},
#' \code{\link{simEstimatorFigures}}
#' @importFrom grDevices pdf dev.off
#' @importFrom graphics plot lines points abline legend par axis plot.new mtext
#' @importFrom splines ns
#' @importFrom sandwich vcovHC
#' @importFrom stats lm glm vcov binomial density setNames sd
#' @export
simCompareMethodsFigures <- function(
output.dirs = c(Bootstrap = "resiBootSim",
Normal = "resiAsympNormalSim",
QF = "resiAsympQFSim",
CF = "resiAsympCFSim"),
figures.dir = NULL,
alpha = 0.05,
fixed.knots = FALSE) {
method_names <- names(output.dirs)
if (is.null(method_names) || any(method_names == ""))
stop("output.dirs must be a named vector (names = CI method labels)")
if (is.null(figures.dir))
figures.dir <- file.path(output.dirs[[1]], "figures", "method_comparison")
dir.create(figures.dir, recursive = TRUE, showWarnings = FALSE)
# Read and merge tables, skipping missing directories
tables <- lapply(seq_along(output.dirs), function(i) {
f <- file.path(output.dirs[[i]], "summary_table.rds")
if (!file.exists(f)) {
warning("summary_table.rds not found in: ", output.dirs[[i]], " -- skipping")
return(NULL)
}
tbl <- readRDS(f)
tbl$ci_method <- method_names[[i]]
tbl
})
tables <- Filter(Negate(is.null), tables)
if (length(tables) == 0L) stop("No valid summary tables found")
combined <- do.call(rbind, tables)
# Keep only methods that were successfully loaded, in supplied order
avail_methods <- intersect(method_names, unique(combined$ci_method))
# High-contrast palette and shapes per method
base_cols <- c("#1F77B4", "#D62728", "#2CA02C", "#FF7F0E", "#9467BD")
method_cols <- setNames(base_cols[seq_along(avail_methods)], avail_methods)
n_vals <- sort(unique(combined$n))
z_ref <- qnorm(1 - alpha / 2)
# Compute true RESI from full insurance dataset for per-term title labels
true_S_lookup <- tryCatch({
insurance_full <- RESI::insurance
if (fixed.knots) {
.age_knots_ts <- stats::quantile(insurance_full$age, c(1/3, 2/3))
.age_bk_ts <- range(insurance_full$age)
lm_form_ts <- eval(bquote(
log10(charges) ~ splines::ns(age, knots = .(.age_knots_ts),
Boundary.knots = .(.age_bk_ts)) *
sex + bmi + smoker + region))
glm_form_ts <- eval(bquote(
I(charges > 15000) ~ splines::ns(age, knots = .(.age_knots_ts),
Boundary.knots = .(.age_bk_ts)) *
sex + bmi + smoker + region))
} else {
lm_form_ts <- log10(charges) ~ splines::ns(age, df = 3) *
sex + bmi + smoker + region
glm_form_ts <- I(charges > 15000) ~ splines::ns(age, df = 3) *
sex + bmi + smoker + region
}
ts_settings <- list(
list(mtype="lm", vtype="parametric", form=lm_form_ts, fam=NULL,
robust=FALSE),
list(mtype="lm", vtype="robust", form=lm_form_ts, fam=NULL,
robust=TRUE),
list(mtype="glm", vtype="parametric", form=glm_form_ts,
fam=stats::binomial(), robust=FALSE),
list(mtype="glm", vtype="robust", form=glm_form_ts,
fam=stats::binomial(), robust=TRUE)
)
result_ts <- list()
for (s_ts in ts_settings) {
key_ts <- paste(s_ts$mtype, s_ts$vtype, sep = "_")
m_ts <- tryCatch({
if (s_ts$mtype == "lm") {
m <- stats::lm(s_ts$form, data = insurance_full)
m$call[["formula"]] <- s_ts$form; m
} else {
m <- stats::glm(s_ts$form, data = insurance_full, family = s_ts$fam)
m$call[["formula"]] <- s_ts$form
m$call[["family"]] <- s_ts$fam; m
}
}, error = function(e) NULL)
if (is.null(m_ts)) next
tv_ts <- if (s_ts$robust)
function(x) sandwich::vcovHC(x, type = "HC0") else stats::vcov
pe_ts <- tryCatch(
resi_pe(m_ts, vcovfunc = tv_ts, unbiased = TRUE),
error = function(e) NULL)
if (!is.null(pe_ts)) result_ts[[key_ts]] <- pe_ts
}
result_ts
}, error = function(e) {
warning("Could not compute true RESI for titles: ", conditionMessage(e))
list()
})
get_true_S <- function(mtype, vtype, tbl_name, term) {
key_ts <- paste(mtype, vtype, sep = "_")
pe_ts <- true_S_lookup[[key_ts]]
if (is.null(pe_ts)) return(NA_real_)
tbl_ts <- pe_ts[[tbl_name]]
if (is.null(tbl_ts) || !(term %in% rownames(tbl_ts))) return(NA_real_)
tbl_ts[term, "RESI"]
}
row_h <- 3.2
leg_h <- 0.55
for (mtype in c("lm", "glm")) {
for (vtype in c("parametric", "robust")) {
for (tbl_name in c("anova", "coefficients")) {
sub <- combined[
combined$model == mtype &
combined$vcov == vtype &
combined$table == tbl_name, ]
if (nrow(sub) == 0L) next
terms <- unique(sub$term)
n_terms <- length(terms)
# Helper: draw method lines/points for a given metric column
draw_method_lines <- function(term_data, metric) {
for (meth in avail_methods) {
md <- term_data[term_data$ci_method == meth, ]
md <- md[order(md$n), ]
if (nrow(md) == 0L) next
graphics::lines(md$n, md[[metric]],
col = method_cols[[meth]], lwd = 2L)
graphics::points(md$n, md[[metric]],
col = method_cols[[meth]], pch = 16L, cex = 0.8)
}
}
# ======================================================
# PDF 1: Estimator figures (Bias + MSE only)
# One row per term, legend at bottom spanning both columns
# ======================================================
fig_path_est <- file.path(
figures.dir,
paste0("estimator_", mtype, "_", vtype, "_", tbl_name, ".pdf")
)
lay_mat_est <- matrix(0L, nrow = n_terms + 1L, ncol = 2L)
for (ti in seq_len(n_terms)) {
lay_mat_est[ti, 1L] <- 2L * (ti - 1L) + 1L # Bias
lay_mat_est[ti, 2L] <- 2L * (ti - 1L) + 2L # MSE
}
leg_id_est <- 2L * n_terms + 1L
lay_mat_est[n_terms + 1L, ] <- leg_id_est
grDevices::pdf(fig_path_est, width = 7,
height = row_h * n_terms + leg_h)
graphics::layout(lay_mat_est,
widths = c(3.2, 3.2),
heights = c(rep(row_h, n_terms), leg_h))
for (ti in seq_along(terms)) {
term <- terms[[ti]]
term_data <- sub[sub$term == term, ]
true_s <- get_true_S(mtype, vtype, tbl_name, term)
term_title <- if (is.finite(true_s))
sprintf("%s: S=%.3f", term, true_s) else term
est_rows <- if (mtype == "glm") term_data$n >= 500 else
rep(TRUE, nrow(term_data))
# ---- Bias ----
ylim <- range(c(term_data[est_rows, "bias"], -0.02, 0.01), na.rm = TRUE)
graphics::par(mar = c(3.2, 3.0, 2.2, 0.5), mgp = c(1.8, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = "Bias",
main = term_title, log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 0, lty = 2L, col = "gray40")
draw_method_lines(term_data, "bias")
# ---- MSE ----
ylim <- range(term_data[est_rows, "mse"], na.rm = TRUE)
graphics::par(mar = c(3.2, 3.0, 2.2, 0.5), mgp = c(1.8, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = ylim,
xlab = "Sample Size", ylab = "MSE",
main = "", log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
draw_method_lines(term_data, "mse")
} # end term loop (estimator PDF)
# ---- Bottom legend (flat/horizontal) ----
graphics::par(mar = c(0.2, 0.2, 0.2, 0.2))
graphics::plot.new()
graphics::legend(
"center",
legend = paste0(avail_methods, " "),
col = method_cols[avail_methods],
pch = 16L, lwd = 2L, lty = 1L,
bty = "n", cex = 0.9,
ncol = length(avail_methods),
title = "CI method", title.font = 2L
)
grDevices::dev.off()
message("Saved: ", fig_path_est)
# ======================================================
# PDF 2: CI comparison (SE cal + stacked coverage + Width)
# Two sub-rows per term (upper/lower cov stacked), legend at bottom
# Layout per term:
# sub-row 1: [SE(span), UpperCov, Width(span)]
# sub-row 2: [SE(span), LowerCov, Width(span)]
# Drawing order per layout reading: SE -> UpperCov -> Width -> LowerCov
# ======================================================
fig_path_ci <- file.path(
figures.dir,
paste0("compare_", mtype, "_", vtype, "_", tbl_name, ".pdf")
)
n_rows_ci <- 2L * n_terms + 1L
lay_mat_ci <- matrix(0L, nrow = n_rows_ci, ncol = 3L)
panel_id <- 1L
for (ti in seq_len(n_terms)) {
r1 <- 2L * ti - 1L; r2 <- 2L * ti
se_id <- panel_id
ucov_id <- panel_id + 1L
wid_id <- panel_id + 2L
lcov_id <- panel_id + 3L
lay_mat_ci[r1, 1L] <- se_id; lay_mat_ci[r2, 1L] <- se_id # SE spans
lay_mat_ci[r1, 2L] <- ucov_id # upper cov
lay_mat_ci[r2, 2L] <- lcov_id # lower cov
lay_mat_ci[r1, 3L] <- wid_id; lay_mat_ci[r2, 3L] <- wid_id # Width spans
panel_id <- panel_id + 4L
}
leg_id_ci <- panel_id
lay_mat_ci[n_rows_ci, ] <- leg_id_ci
sub_h <- row_h / 2 # height of each coverage sub-row (half of a full row)
grDevices::pdf(fig_path_ci, width = 11,
height = row_h * n_terms + leg_h)
graphics::layout(lay_mat_ci,
widths = c(3.5, 3.0, 3.0),
heights = c(rep(c(sub_h, sub_h), n_terms), leg_h))
for (ti in seq_along(terms)) {
term <- terms[[ti]]
term_data <- sub[sub$term == term, ]
true_s <- get_true_S(mtype, vtype, tbl_name, term)
term_title <- if (is.finite(true_s))
sprintf("%s: S=%.3f", term, true_s) else term
# Shared coverage y-range (same scale for upper + lower)
# No n-based clipping: use all sample sizes for both lm and glm.
cov_vals <- c(term_data[["upper_coverage"]], term_data[["lower_coverage"]])
cov_ylim <- range(c(cov_vals, 1 - alpha/2 - 0.02, 1.01), na.rm = TRUE)
# Width y-range: clip extreme values (1.5 for glm, 10 for lm)
max_wid <- if (mtype == "glm") 1.5 else 10
wid_vals <- term_data[["width"]]
wid_ylim <- range(wid_vals[wid_vals <= max_wid], na.rm = TRUE)
# Drawing order: SE (spans) -> UpperCov -> Width (spans) -> LowerCov
# ---- Panel 1: SE calibration (spans both sub-rows, term title here) ----
se_comp <- do.call(rbind, lapply(avail_methods, function(meth) {
md <- term_data[term_data$ci_method == meth, ]
md <- md[order(md$n), ]
data.frame(
ci_method = meth,
n = md$n,
emp_se = sqrt(md$n) * md$empirical_sd,
half_width = sqrt(md$n) * md$width / (2 * z_ref),
stringsAsFactors = FALSE
)
}))
se_vals <- c(se_comp$emp_se, se_comp$half_width)
max_se <- if (mtype == "glm") 3 else 10
lim_se <- range(se_vals[se_vals <= max_se], na.rm = TRUE)
graphics::par(mar = c(3.2, 3.2, 2.2, 0.5), mgp = c(1.8, 0.5, 0))
graphics::plot(
NULL, xlim = lim_se, ylim = lim_se,
xlab = expression(sqrt(n) %*% " Empirical SE"),
ylab = expression(sqrt(n) %*% " CI Half-Width / " * z[alpha/2]),
main = term_title, bty = "l", asp = 1
)
graphics::abline(0, 1, lty = 2L, col = "gray40")
for (meth in avail_methods) {
md_se <- se_comp[se_comp$ci_method == meth, ]
md_se <- md_se[order(md_se$n), ]
if (nrow(md_se) == 0L) next
graphics::points(md_se$emp_se[1L], md_se$half_width[1L],
col = method_cols[[meth]], pch = 16L, cex = 1.1)
if (nrow(md_se) >= 2L) {
for (i in seq_len(nrow(md_se) - 1L)) {
graphics::arrows(
md_se$emp_se[i], md_se$half_width[i],
md_se$emp_se[i+1L], md_se$half_width[i+1L],
col = method_cols[[meth]], lwd = 1.5, length = 0.07, angle = 20L
)
}
}
}
# ---- Panel 2: Upper coverage (top sub-row, no x-axis) ----
graphics::par(mar = c(0.3, 3.0, 2.2, 0.5), mgp = c(1.8, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = cov_ylim,
xlab = "", ylab = "Upper Cov.",
main = "", log = "x", xaxt = "n", bty = "l")
graphics::abline(h = 1 - alpha/2, lty = 2L, col = "gray40")
draw_method_lines(term_data, "upper_coverage")
# ---- Panel 3: Width (spans both sub-rows) ----
graphics::par(mar = c(3.2, 3.0, 2.2, 0.5), mgp = c(1.8, 0.5, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = wid_ylim,
xlab = "Sample Size", ylab = "CI Width",
main = "", log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
draw_method_lines(term_data, "width")
# ---- Panel 4: Lower coverage (bottom sub-row, with x-axis) ----
graphics::par(mar = c(2.8, 3.0, 0.3, 0.5), mgp = c(1.8, 0.45, 0))
graphics::plot(NULL, xlim = range(n_vals), ylim = cov_ylim,
xlab = "Sample Size", ylab = "Lower Cov.",
main = "", log = "x", xaxt = "n", bty = "l")
graphics::axis(1, at = n_vals, labels = n_vals, las = 2L,
mgp = c(1.7, 0.35, 0))
graphics::abline(h = 1 - alpha/2, lty = 2L, col = "gray40")
draw_method_lines(term_data, "lower_coverage")
} # end term loop (CI PDF)
# ---- Bottom legend (flat/horizontal) ----
graphics::par(mar = c(0.2, 0.2, 0.2, 0.2))
graphics::plot.new()
graphics::legend(
"center",
legend = paste0(avail_methods, " "),
col = method_cols[avail_methods],
pch = 16L, lwd = 2L, lty = 1L,
bty = "n", cex = 0.9,
ncol = length(avail_methods),
title = "CI method", title.font = 2L
)
grDevices::dev.off()
message("Saved: ", fig_path_ci)
}
}
}
# ============================================================
# Coverage-quantile figures
# (S_true - LCI) / (UCI - LCI) density curves
# One PDF per (model x vcov x table): rows = terms, columns = n values
# ============================================================
message("Generating coverage-quantile figures...")
insurance <- RESI::insurance
ci_lo_col_cq <- paste0(alpha / 2 * 100, "%")
ci_hi_col_cq <- paste0((1 - alpha / 2) * 100, "%")
if (fixed.knots) {
.age_knots <- stats::quantile(insurance$age, c(1/3, 2/3))
.age_bk <- range(insurance$age)
lm_formula_cq <- eval(bquote(
log10(charges) ~ splines::ns(age, knots = .(.age_knots),
Boundary.knots = .(.age_bk)) *
sex + bmi + smoker + region))
glm_formula_cq <- eval(bquote(
I(charges > 15000) ~ splines::ns(age, knots = .(.age_knots),
Boundary.knots = .(.age_bk)) *
sex + bmi + smoker + region))
} else {
lm_formula_cq <- log10(charges) ~ splines::ns(age, df = 3) *
sex + bmi + smoker + region
glm_formula_cq <- I(charges > 15000) ~ splines::ns(age, df = 3) *
sex + bmi + smoker + region
}
cq_settings <- list(
list(type = "lm", key = "lm_parametric",
formula = lm_formula_cq, family = NULL,
vcovfunc = stats::vcov, robust = FALSE),
list(type = "lm", key = "lm_robust",
formula = lm_formula_cq, family = NULL,
vcovfunc = sandwich::vcovHC, robust = TRUE),
list(type = "glm", key = "glm_parametric",
formula = glm_formula_cq, family = stats::binomial(),
vcovfunc = stats::vcov, robust = FALSE),
list(type = "glm", key = "glm_robust",
formula = glm_formula_cq, family = stats::binomial(),
vcovfunc = sandwich::vcovHC, robust = TRUE)
)
# Compute true RESI from full insurance dataset
cq_true <- lapply(cq_settings, function(s) {
full_mod <- tryCatch({
if (s$type == "lm") {
m <- stats::lm(s$formula, data = insurance)
m$call[["formula"]] <- s$formula; m
} else {
m <- stats::glm(s$formula, data = insurance, family = s$family)
m$call[["formula"]] <- s$formula
m$call[["family"]] <- s$family; m
}
}, error = function(e) NULL)
if (is.null(full_mod)) return(NULL)
# Use HC0 for robust (no hat-value correction at full n)
tv_vcov <- if (s$robust) function(x) sandwich::vcovHC(x, type = "HC0")
else s$vcovfunc
tryCatch(resi_pe(full_mod, vcovfunc = tv_vcov, unbiased = TRUE),
error = function(e) NULL)
})
names(cq_true) <- vapply(cq_settings, `[[`, character(1L), "key")
for (mtype_cq in c("lm", "glm")) {
for (vtype_cq in c("parametric", "robust")) {
key_cq <- paste0(mtype_cq, "_", vtype_cq)
pe_true <- cq_true[[key_cq]]
if (is.null(pe_true)) next
for (tbl_cq in c("anova", "coefficients")) {
true_t <- pe_true[[tbl_cq]]
if (is.null(true_t) || nrow(true_t) == 0L) next
terms_cq <- rownames(true_t)
terms_cq <- setdiff(terms_cq, c("(Intercept)", "Residuals"))
if (length(terms_cq) == 0L) next
n_vals_cq <- sort(unique(combined$n))
# Load per-replicate (LCI, UCI) for every (method, n, term)
cq_dat <- lapply(setNames(avail_methods, avail_methods), function(meth) {
raw_dir <- file.path(output.dirs[[meth]], "sim_raw")
if (!dir.exists(raw_dir)) return(NULL)
lapply(setNames(n_vals_cq, as.character(n_vals_cq)), function(n_val) {
f <- file.path(raw_dir, paste0(key_cq, "_n", n_val, ".rds"))
if (!file.exists(f)) return(NULL)
reps <- tryCatch(readRDS(f), error = function(e) NULL)
if (is.null(reps) || length(reps) == 0L) return(NULL)
lapply(setNames(terms_cq, terms_cq), function(term) {
s_true <- true_t[term, "RESI"]
lci_v <- vapply(reps, function(r) {
tbl_r <- r[[tbl_cq]]
if (is.null(tbl_r) || !(term %in% rownames(tbl_r))) return(NA_real_)
if (!(ci_lo_col_cq %in% colnames(tbl_r))) return(NA_real_)
tbl_r[term, ci_lo_col_cq]
}, numeric(1L))
uci_v <- vapply(reps, function(r) {
tbl_r <- r[[tbl_cq]]
if (is.null(tbl_r) || !(term %in% rownames(tbl_r))) return(NA_real_)
if (!(ci_hi_col_cq %in% colnames(tbl_r))) return(NA_real_)
tbl_r[term, ci_hi_col_cq]
}, numeric(1L))
cq_v <- (s_true - lci_v) / (uci_v - lci_v)
cq_v[!is.finite(cq_v)] <- NA_real_
cq_v
})
})
})
# Build PDF
n_terms_cq <- length(terms_cq)
n_sizes_cq <- length(n_vals_cq)
n_panels_cq <- n_terms_cq * n_sizes_cq
lay_cq <- matrix(seq_len(n_panels_cq),
nrow = n_terms_cq, ncol = n_sizes_cq,
byrow = TRUE)
lay_cq <- cbind(lay_cq, n_panels_cq + 1L)
col_w_cq <- 2.4
row_h_cq <- 2.4
fig_path_cq <- file.path(
figures.dir,
paste0("covquant_", mtype_cq, "_", vtype_cq, "_", tbl_cq, ".pdf")
)
grDevices::pdf(fig_path_cq,
width = col_w_cq * n_sizes_cq + 2.2,
height = row_h_cq * n_terms_cq + 0.5)
graphics::layout(lay_cq,
widths = c(rep(col_w_cq, n_sizes_cq), 2.2),
heights = rep(row_h_cq, n_terms_cq))
for (ti in seq_along(terms_cq)) {
term_cq <- terms_cq[[ti]]
is_first_row <- (ti == 1L)
is_last_row <- (ti == n_terms_cq)
for (ni in seq_along(n_vals_cq)) {
n_cq <- n_vals_cq[[ni]]
is_first_col <- (ni == 1L)
# Compute density for each method
dens_list_cq <- lapply(setNames(avail_methods, avail_methods), function(meth) {
vals <- cq_dat[[meth]][[as.character(n_cq)]][[term_cq]]
vals <- vals[is.finite(vals)]
if (length(vals) < 5L) return(NULL)
tryCatch(stats::density(vals, n = 256L), error = function(e) NULL)
})
xlim_cq <- {
all_x <- unlist(c(
lapply(dens_list_cq, function(d) if (!is.null(d)) range(d$x)),
list(c(0, 1))))
c(min(all_x) - 0.05, max(all_x) + 0.05)
}
max_y_cq <- max(
vapply(dens_list_cq,
function(d) if (!is.null(d)) max(d$y) else 0, numeric(1L)),
1.0)
ylim_cq <- c(0, max_y_cq * 1.1)
t_mar <- if (is_first_row) 1.8 else 0.4
b_mar <- if (is_last_row) 2.5 else 0.4
l_mar <- if (is_first_col) 3.2 else 1.2
graphics::par(mar = c(b_mar, l_mar, t_mar, 0.3), mgp = c(1.5, 0.4, 0))
graphics::plot(NULL, xlim = xlim_cq, ylim = ylim_cq,
xlab = if (is_last_row) "(S_true - LCI) / Width" else "",
ylab = if (is_first_col) "Density" else "",
main = if (is_first_row) paste0("n = ", n_cq) else "",
bty = "l",
xaxt = if (is_last_row) "s" else "n",
yaxt = if (is_first_col) "s" else "n")
# Row label (term name) on left margin of first column
if (is_first_col)
graphics::mtext(term_cq, side = 2L, line = 2.0,
cex = 0.65, las = 0, font = 2L)
# Reference lines: 0/1 = CI boundaries, 0.5 = midpoint, y=1 = uniform
graphics::abline(v = c(0, 1), lty = 2L, col = "gray50", lwd = 0.8)
graphics::abline(v = 0.5, lty = 3L, col = "gray70", lwd = 0.8)
graphics::abline(h = 1.0, lty = 3L, col = "gray80", lwd = 0.8)
for (meth in avail_methods) {
d <- dens_list_cq[[meth]]
if (is.null(d)) next
graphics::lines(d, col = method_cols[[meth]], lwd = 1.8)
}
}
}
# Shared legend
graphics::par(mar = c(0.3, 0.3, 0.3, 0.3))
graphics::plot.new()
graphics::legend("center",
legend = avail_methods,
col = method_cols[avail_methods],
lwd = 2L, lty = 1L,
bty = "n", cex = 0.9,
title = "CI method", title.font = 2L)
grDevices::dev.off()
message("Saved: ", fig_path_cq)
}
}
}
message("Comparison figures saved to: ", figures.dir)
invisible(combined)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.