Compares simulated groups against observed groups on the statistics that matter for a tabletop exercise, and reports one row per statistic. The headline is the dispersion ratio: the between-group standard deviation of a behavioural rate, observed divided by simulated. A simulator that reproduces the average behaviour but makes every team look the same fails here, which is the whole point of the function.
Arguments
- x
The simulated groups: an
sdt_simulated_groupsobject fromsdt_simulate_groups(), agame_runfromsdt_simulate()(converted withsdt_run_behaviour()), or a plain data frame with one row per (group, card).- observed
The observed groups, in the same shape. Optional when
xcarries its fitted model, in which case the model's own training frame is used.- statistics
Which statistics to score.
NULL(the default) scores every behavioural column the two frames share. A character vector selects columns by name. A named list of functions defines derived statistics; each is called with a data frame and must return one logical or numeric value per row, and each is applied to both frames, so the columns it uses must exist in both. A shared character or factor column is expanded into one statistic per level, namedcolumn=level.- n_boot
Bootstrap replicates for the interval on
ratio.0skips the bootstrap and returnsNAbounds.- seed
Optional integer seed, for a reproducible bootstrap.
Value
A tibble with one row per statistic, ordered with
the failures first: statistic, observed, simulated, ratio,
ci_lower, ci_upper, verdict, control, n_obs, n_sim, and then
observed_mean, simulated_mean, location_z, overlap, the four
percentile columns, reason, n_obs_rows, n_sim_rows,
n_obs_missing, n_sim_missing and n_sim_reps. n_obs and n_sim
count groups – the sample size of each standard deviation, with n_sim
counted per simulated replicate – while the _rows columns count the
(group, card) episodes behind them and the _missing columns count the
episodes that statistic could not be computed on, so a rate built on a
partly missing column cannot pass unnoticed.
What is compared
For each statistic, each group gets one number – the share of its patient cards on which the statistic was true. The spread of those group-level rates is the quantity of interest, and the columns report:
observed,simulated: the between-group SD of the group rates.ratio:observed / simulated.Infwhen the simulator produces no between-group spread at all, which is a real and reportable result.ci_lower,ci_upper: a percentile bootstrap interval onratio, resampling groups on each side with the simulated cohort held fixed. It answers "how much would this move with a different sample of teams?".rep_lower,rep_upper,rep_covers_one: the interval across replicates –observed / SD(replicate)for each simulated cohort, at the 2.5th and 97.5th percentiles. It answers the other question, "how much would this move on a different RUN of the simulator?", which is the one that dominates for a rare behaviour and which the bootstrap cannot see. Needs at least ten replicates;NAbelow that.observed_mean,simulated_mean,location_z: the level check.location_zis the difference in pooled rates expressed in observed between-group SDs, so a model can also fail by getting the level wrong.overlap: the overlapping coefficient of the two distributions of group-level rates, from 0 (disjoint) to 1 (identical). Together with theobserved_p10/observed_p90/simulated_p10/simulated_p90columns it shows how far the simulated teams reach, rather than only how wide they are on average.verdict:"PASS"only when the dispersion ratio lies inside 0.90 and 1.15 andabs(location_z)is at most 0.25.reasonnames whichever check failed.
The full per-group rates behind the summary are attached as the
"group_rates" attribute of the result, and the frame the comparison ran
on as "frame".
The composition-matched frame
Between-group dispersion is only meaningful when every group is scored on
the same cards. sdt_fidelity() enforces that: it intersects the card sets
of the two frames, then keeps only groups that saw every card in the
intersection, and records what it dropped in the "frame" attribute. On
the real course corpus, skipping this step reports no team signal at all,
because each truncated group saw a different deck and that composition
variance swamps the behavioural one.
Negative controls
Outcomes fixed by the patient card rather than chosen by the team –
died and ventilated by default – are marked control = TRUE. They are
the pipeline's own alarm, and they read backwards from the other rows: a
control is expected to pass even for a simulator with no team term at
all, so a failing control means the frame or the pipeline is wrong, not
the model. Check the matched frame before you believe anything else in
the table. The one exception is a simulator with no team-level randomness
at all: the discrete-event engine reads died and ventilated off the
patient card and never redraws them, so its controls are dead flat and
fail too. That is a finding about the engine – it has no team layer of
any kind – rather than about the frame, and the way to tell the two apart
is that a broken frame moves the controls a little while a missing team
layer flattens them completely.
What the bootstrap resamples
Whole groups, with replacement, independently on each side, and the ratio is recomputed from the resampled group rates. The group is the unit the dispersion is a statement about and the unit that is exchangeable in both frames; resampling individual (group, card) episodes instead would break the very clustering being measured and would report an interval far too narrow. Where the simulated frame carries several replicates, each is resampled within itself and the resulting SDs averaged, exactly as the point estimate is formed.
See also
sdt_plot_fidelity() to draw the result,
sdt_run_behaviour() to score the discrete-event engine on the same
statistics.
Examples
set.seed(1)
cards <- sprintf("c%02d", 1:6)
# Ten observed groups that genuinely differ from one another.
obs <- expand.grid(group_id = sprintf("g%02d", 1:10), card = cards,
stringsAsFactors = FALSE)
team <- stats::setNames(stats::runif(10, 0.1, 0.9), sprintf("g%02d", 1:10))
obs$surgery <- stats::runif(nrow(obs)) < team[obs$group_id]
# A simulator that gets the average right and the spread badly wrong.
sim <- expand.grid(sim_group = sprintf("s%02d", 1:10), card = cards,
stringsAsFactors = FALSE)
sim$surgery <- stats::runif(nrow(sim)) < mean(team)
sdt_fidelity(sim, obs, n_boot = 50, seed = 1)
#> # A tibble: 1 × 34
#> statistic observed simulated ratio ci_lower ci_upper verdict control n_obs
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <chr> <lgl> <int>
#> 1 surgery 0.375 0.238 1.57 0.972 2.40 FAIL FALSE 10
#> # ℹ 25 more variables: n_sim <int>, observed_mean <dbl>, simulated_mean <dbl>,
#> # location_z <dbl>, overlap <dbl>, observed_p10 <dbl>, observed_p90 <dbl>,
#> # simulated_p10 <dbl>, simulated_p90 <dbl>, sd_p <dbl>, rep_lower <dbl>,
#> # rep_upper <dbl>, rep_covers_one <lgl>, observed_floor <dbl>,
#> # simulated_floor <dbl>, observed_between <dbl>, simulated_between <dbl>,
#> # ratio_between <dbl>, ci_overlaps_band <lgl>, reason <chr>,
#> # n_obs_rows <int>, n_sim_rows <int>, n_obs_missing <int>, …