This simulation study uses branches, cached execution, and explicit gate decisions. The workflow follows the simulation-oriented perspective of https://arxiv.org/abs/2408.06504 and https://arxiv.org/abs/2210.06927 and uses base R model fitting.
When treatment assignment is confounded, does an adjustment-aware model outperform a naive model that omits the confounder?
Register node kinds
The graph uses these node kinds:
- a scenario node defines the data-generating condition,
- simulation nodes generate train/test data,
- a descriptive node summarizes imbalance,
- two fit nodes branch into competing model choices,
- a comparison node ranks the branches after an explicit decision gate.
bg_register_node_kind(handle, "scenario", executor = function(node, inputs) {
node$params
})
bg_register_node_kind(handle, "simulate_data", executor = function(node, inputs) {
scenario <- inputs[[1]]
set.seed(scenario$seed + node$params$seed_offset)
n <- scenario$n
z <- rnorm(n)
treatment_prob <- plogis(scenario$assignment_intercept + scenario$confounding * z)
treatment <- rbinom(n, size = 1, prob = treatment_prob)
outcome <- 0.5 + scenario$beta_treat * treatment + scenario$beta_z * z + rnorm(n, sd = scenario$sigma)
data <- data.frame(
z = z,
treatment = treatment,
outcome = outcome
)
list(
data = data,
truth = list(
beta_treat = scenario$beta_treat,
beta_z = scenario$beta_z,
confounding = scenario$confounding
),
split = node$params$split
)
})
bg_register_node_kind(handle, "descriptive_check", executor = function(node, inputs) {
sim <- inputs[[1]]
data <- sim$data
treated <- data[data$treatment == 1, , drop = FALSE]
untreated <- data[data$treatment == 0, , drop = FALSE]
pooled_sd <- stats::sd(data$z)
list(
split = sim$split,
treated_share = mean(data$treatment),
mean_z_treated = mean(treated$z),
mean_z_untreated = mean(untreated$z),
z_smd = (mean(treated$z) - mean(untreated$z)) / pooled_sd,
cor_treatment_z = stats::cor(data$treatment, data$z)
)
})
bg_register_node_kind(handle, "fit_lm", executor = function(node, inputs) {
train <- inputs[[1]]
test <- inputs[[2]]
formula <- stats::as.formula(node$params$formula)
fit <- stats::lm(formula, data = train$data)
predictions <- stats::predict(fit, newdata = test$data)
rmse <- sqrt(mean((test$data$outcome - predictions)^2))
estimate <- unname(stats::coef(fit)[["treatment"]])
list(
model_label = node$label,
formula = node$params$formula,
estimate = estimate,
absolute_effect_error = abs(estimate - train$truth$beta_treat),
predictive_rmse = rmse
)
})
bg_register_node_kind(handle, "compare_models", executor = function(node, inputs) {
naive <- inputs[[1]]
adjusted <- inputs[[2]]
descriptive <- inputs[[3]]
ranking <- data.frame(
model = c(naive$model_label, adjusted$model_label),
absolute_effect_error = c(
naive$absolute_effect_error,
adjusted$absolute_effect_error
),
predictive_rmse = c(naive$predictive_rmse, adjusted$predictive_rmse)
)
ranking <- ranking[order(ranking$absolute_effect_error, ranking$predictive_rmse), ]
list(
imbalance = descriptive,
ranking = ranking,
recommended = ranking$model[[1]]
)
})Build two scenario branches
We construct two branches:
- a near-randomized assignment scenario,
- a confounded assignment scenario.
build_branch <- function(handle, label, confounding, seed) {
scenario <- bg_add_node(
handle,
kind = "scenario",
label = sprintf("%s scenario", label),
params = list(
n = 250,
beta_treat = 1.0,
beta_z = 0.8,
sigma = 1.0,
assignment_intercept = 0,
confounding = confounding,
seed = seed
)
)
train <- bg_add_node(
handle,
kind = "simulate_data",
label = sprintf("%s train", label),
inputs = scenario,
params = list(split = "train", seed_offset = 1)
)
test <- bg_add_node(
handle,
kind = "simulate_data",
label = sprintf("%s test", label),
inputs = scenario,
params = list(split = "test", seed_offset = 1001)
)
descriptive <- bg_add_node(
handle,
kind = "descriptive_check",
label = sprintf("%s descriptive check", label),
inputs = train
)
naive_fit <- bg_add_node(
handle,
kind = "fit_lm",
label = sprintf("%s naive model", label),
inputs = c(train, test),
params = list(formula = "outcome ~ treatment")
)
adjusted_fit <- bg_add_node(
handle,
kind = "fit_lm",
label = sprintf("%s adjusted model", label),
inputs = c(train, test),
params = list(formula = "outcome ~ treatment + z")
)
compare <- bg_add_node(
handle,
kind = "compare_models",
label = sprintf("%s comparison", label),
inputs = c(naive_fit, adjusted_fit, descriptive)
)
gate <- bg_add_gate(
handle,
from = descriptive,
to = compare,
prompt = sprintf(
"Does the descriptive imbalance in the %s scenario justify relying on the adjustment-aware model?",
label
),
alternatives = c("yes", "no"),
refs = list(
list(
citekey = "scholz2024causalproxy",
url = "https://arxiv.org/abs/2210.06927"
)
)
)
list(
scenario = scenario,
train = train,
test = test,
descriptive = descriptive,
naive_fit = naive_fit,
adjusted_fit = adjusted_fit,
compare = compare,
gate = gate
)
}
randomized <- build_branch(handle, "Randomized", confounding = 0.0, seed = 101)
confounded <- build_branch(handle, "Confounded", confounding = 1.1, seed = 202)Run up to the decision gates
The comparison nodes are blocked, but the descriptive summaries and both model branches can already run.
first_run <- bg_run(handle)
#> Starting run "run_f47944bd2f0f6b33" with 2 nodes to execute.
#> Running node "node_533987f32efbd9d4"...
#> Running node "node_16aa4d38d731de04"...
#> Running node "node_27953a7e5917925b"...
#> Running node "node_6b72741f96c65393"...
#> Running node "node_50ab11b84ba90027"...
#> Running node "node_2e689b00e5595f7d"...
#> Running node "node_5f21720b2f98bdc3"...
#> Running node "node_457026f8d353526e"...
#> Running node "node_2c93efb68198c075"...
#> Running node "node_832b6476f00290d6"...
#> Running node "node_6d6719b13effb7c9"...
#> Running node "node_1d78a649995eaf50"...
first_run$summary
#> $total_executed
#> [1] 12
bg_status(handle)
#> $workflow_state
#> [1] "blocked"
#>
#> $runnable_nodes
#> [1] 0
#>
#> $blocked_nodes
#> [1] 2
#>
#> $pending_gates
#> [1] 2
#>
#> $gates_missing_specs
#> list()
#>
#> $active_jobs
#> [1] 0
#>
#> $last_run_id
#> [1] "run_f47944bd2f0f6b33"
#>
#> $health
#> [1] "warning"
#>
#> $messages
#> [1] "2 pending gate(s) block downstream work."Inspect the descriptive summaries that motivated the gate answers:
bg_result(handle, randomized$descriptive)
#> $split
#> [1] "train"
#>
#> $treated_share
#> [1] 0.46
#>
#> $mean_z_treated
#> [1] 0.1046877
#>
#> $mean_z_untreated
#> [1] -0.03377925
#>
#> $z_smd
#> [1] 0.1345947
#>
#> $cor_treatment_z
#> [1] 0.06721623
bg_result(handle, confounded$descriptive)
#> $split
#> [1] "train"
#>
#> $treated_share
#> [1] 0.5
#>
#> $mean_z_treated
#> [1] 0.6228693
#>
#> $mean_z_untreated
#> [1] -0.3738931
#>
#> $z_smd
#> [1] 0.9888632
#>
#> $cor_treatment_z
#> [1] 0.4954234Answer the gates
The randomized branch should not show systematic imbalance, whereas the confounded branch usually does. Record that distinction in the gate decisions before running the comparison nodes.
bg_answer_gate(
handle,
randomized$gate$id,
choice = "no",
rationale = paste(
"The standardized mean difference for z is small, so the adjustment branch",
"is not required to justify interpretation here."
),
evidence = randomized$descriptive,
refs = list(
list(
citekey = "scholz2024causalproxy",
url = "https://arxiv.org/abs/2210.06927"
)
)
)
#> <bg_decision_record> dec_f481e72bd105837b
#> • Prompt: Does the descriptive imbalance in the Randomized scenario justify
#> relying on the adjustment-aware model?
#> • Choice: no
#> • Rationale: The standardized mean difference for z is small, so the adjustment
#> branch is not required to justify interpretation here.
bg_answer_gate(
handle,
confounded$gate$id,
choice = "yes",
rationale = paste(
"Treatment assignment is visibly associated with z, so the adjusted branch",
"is the only trustworthy explanatory candidate."
),
evidence = confounded$descriptive,
refs = list(
list(
citekey = "scholz2024causalproxy",
url = "https://arxiv.org/abs/2210.06927"
)
)
)
#> <bg_decision_record> dec_1b353aecfbd936d9
#> • Prompt: Does the descriptive imbalance in the Confounded scenario justify
#> relying on the adjustment-aware model?
#> • Choice: yes
#> • Rationale: Treatment assignment is visibly associated with z, so the adjusted
#> branch is the only trustworthy explanatory candidate.Run the comparisons
second_run <- bg_run(handle)
#> Starting run "run_31db87fca427c8e5" with 2 nodes to execute.
#> Running node "node_f5fd03abb05d0a82"...
#> Running node "node_8086c9b2f7cb8dea"...
second_run$summary
#> $total_executed
#> [1] 2Now the comparison nodes can summarize the two branches:
bg_result(handle, randomized$compare)$ranking
#> model absolute_effect_error predictive_rmse
#> 2 Randomized adjusted model 0.02457046 0.964850
#> 1 Randomized naive model 0.13661777 1.294119
bg_result(handle, confounded$compare)$ranking
#> model absolute_effect_error predictive_rmse
#> 2 Confounded adjusted model 0.02950755 1.062609
#> 1 Confounded naive model 0.72121614 1.264224In a typical run, the randomized scenario keeps the two models close, while the confounded scenario penalizes the naive model more strongly. The graph records each branch, reuses cached results, and stores the reasons for each next step alongside the computation state.
Export an audit report
report_path <- bg_export_report(handle, path = "simulation-study-report.md", format = "md")
#> Report exported to
#> /tmp/Rtmp2NPJ8x/bg-simulation-study/simulation-study-report.md
report_path
#> [1] "/tmp/Rtmp2NPJ8x/bg-simulation-study/simulation-study-report.md"The report includes graph topology, decision provenance, gate context, evidence nodes, and the artifact index for the current run.
Workflow packs
Optional built-in packs add summary-driven review:
-
bayesgrove.process_guidancefor workflow preflight, iterative repair, and out-of-sample stability review, -
bayesgrove.model_taxonomyfor PAD classification and utility trade-offs, -
bayesgrove.prior_workflowfor prior rationale and prior predictive review, -
bayesgrove.model_checksfor posterior predictive checks and, onlatent_inferencebranches, SBC and LOO-PIT calibration review, -
bayesgrove.model_selectionfor fresh comparison evidence, includingcomparison_resultsand stacking weights, -
bayesgrove.stan_workflowfor Stan diagnostics and projection-predictive review, and -
bayesgrove.causal_dagittyfor DAG-based adjustment review, implication review, and causal selection contracts that can constrain formulas or projection-based variable selection.