Fits one binomial mixed model per outcome to observed (group, card) decision episodes:
Usage
sdt_fit_behaviour(
data,
outcomes = NULL,
group = "group_id",
block = "session_id",
card = "card",
engine = "lme4",
nAGQ = 1L,
frame = c("matched", "all"),
min_coverage = NULL,
block_data = NULL,
block_covariates = NULL,
verbose = TRUE
)Arguments
- data
A data frame of decision episodes, one row per (group, card).
- outcomes
Character vector of outcome names, or
NULLto auto-detect.- group, block, card
Column names holding the team identifier, the session (block) the team played in, and the patient card.
blockmay beNULLto fit without a session term. Group labels that repeat across blocks are nested inside their block, with a warning.- engine
"lme4"(default) or"glmmTMB".- nAGQ
Quadrature points for the lme4 engine.
1(default) is the Laplace approximation. A larger value uses adaptive Gauss-Hermite quadrature, which removes most of the upward bias the Laplace approximation carries in the variance component when clusters hold very few events – the failure mode behind an over-dispersed simulated cohort for a rare behaviour. lme4 supports it only for a model with one grouping factor, so it requiresblock = NULL; asking for it on a cross-classified model is an error rather than a silent downgrade.- frame
"matched"(default) for the composition-matched frame, or"all"for every row.- min_coverage
Minimum number of groups a card must be seen by to enter the matched frame.
NULLchooses it from the data.- block_data
Optional data frame with one row per block, keyed by the
blockcolumn, holding block-level covariates – typically the output ofsdt_course_composition(), which keys onfile.- block_covariates
Optional character vector naming the columns of
block_datathat enter the fixed part of the formula. Passed together withblock_dataor not at all.- verbose
If
TRUE, report progress via cli.
Value
An S3 object of class sdt_behaviour_model:
fitsNamed list of fitted models, one per modelled outcome.
outcomesNames of the outcomes that were modelled.
frame"matched"or"all", as requested.termsThe formula template and the column names it was built from, including
block_covariates.dataThe frame actually fitted on, with the derived outcome columns attached.
metaCounts, the frame definition, engine, package versions, runtime, and
sds: per outcome, the rows used, the rows dropped as missing, the prevalence, and the group and block standard deviations on the logit scale. With block covariates it also carriescomposition(the centring and scaling used) andblock_variance(the block SD before and after, per outcome).skippedOne row per outcome not modelled, with the reason.
Details
The card fixed effect carries what the casualty is; the group random effect
carries what the team is. That second term is the point. It is what lets
sdt_simulate_groups() draw a new team rather than another replication of
the ones already in the data, and it is what the discrete-event engine in
sdt_simulate() has no equivalent of.
The composition-matched frame
Real play is truncated by the clock, so groups see different subsets of the
deck. Any between-group statistic computed on that unbalanced frame mixes
team behaviour with which cards the team reached, and the second term is
much larger than the first. frame = "matched" therefore keeps only cards
seen by at least min_coverage groups and only the groups that saw all of
them, so every group in the fit answered the same questions. frame = "all"
fits on everything and warns: a simulator deals every card to every group,
so it cannot reproduce composition spread, and the dispersion ratio is then
wrong for a reason that has nothing to do with the model. Measured on the
validation corpus, the full deck reports 1.10 for TASC and 1.33 for eFAST
where the matched frame built from the same rows reports 1.00 and 1.02.
With min_coverage = NULL the threshold is chosen to maximise cards times
groups – the number of complete decision episodes – with ties going to the
frame that keeps more groups.
Course composition as a block covariate
block_data is a table with one row per block, keyed by the block
column – the shape sdt_course_composition() returns, which keys on
file, so fit with block = "file" to join on it. block_covariates
names the columns that enter the fixed part, giving
The random block intercept stays. The covariates are there to explain part of the session spread, not to replace the term, and with fifteen informative courses a handful of between-course slopes cannot be trusted to replace it.
They also cannot reach further than that. Participants are linked to
courses, never to the groups inside them, so composition is constant
within a block: it competes with the session random effect and can
never explain the team random effect, which is the one
sdt_simulate_groups() draws a new team from. A course-level covariate
cannot make a simulated team more or less like any other team in its own
course.
Numeric and logical covariates are accepted; each is centred and scaled
over the distinct blocks, so a course counts once however many groups it
ran, and the centring is recorded in $meta$composition$covariates so the
simulator can invert it. A covariate that is constant across blocks, that
is missing for a block being fitted, or that is collinear with another is
refused, and every offender is named in one message.
Each outcome is then fitted twice, with the covariates and without
them on exactly the same rows, and $meta$block_variance reports the
block SD before and after, the share of block variance removed, and the
deviance and AIC change. That doubles the fitting time and it is the point
of the exercise: the answer is allowed to be "nothing", and on this corpus
it usually is.
Outcomes
outcomes = NULL auto-detects everything the catalogue can build from the
columns present: tasc, dcs, etc and conservative from therapy or
the did_* flags; efast, xray and ct from did_efast, did_xray and
did_ct; red, yellow and green from called_triage; icu and
stranded from general_location; ventilated and died from the columns
of those names. You may instead pass a character vector naming catalogue
outcomes, logical columns of data, or both.
A consensus or modal triage label is deliberately not a source. It is derived from what the teams did, so modelling it would model the model.
What gets skipped
An outcome that cannot be built, is constant, has too few events or groups,
or whose fit fails or does not converge is recorded in $skipped with its
reason, never dropped in silence. A fit that converges to a zero team
variance is kept and flagged singular: that is a finding about the
outcome – teams did not differ – and hiding it would hide the negative
result the fidelity report exists to surface. A fit whose team SD lands
above 5 on the logit scale is flagged boundary for the mirror-image
reason: that is separation, and simulated teams will be over-dispersed.
See also
sdt_simulate_groups() to draw new teams from the fit.
Examples
set.seed(1)
d <- expand.grid(
card = sprintf("c%02d", seq_len(8)),
group_id = sprintf("g%02d", seq_len(12)),
stringsAsFactors = FALSE
)
d$session_id <- ifelse(d$group_id < "g07", "s1", "s2")
b <- stats::rnorm(12, sd = 1.2)
p <- stats::plogis(-0.5 + b[match(d$group_id, unique(d$group_id))])
d$did_efast <- stats::runif(nrow(d)) < p
if (requireNamespace("lme4", quietly = TRUE)) {
fit <- sdt_fit_behaviour(d, outcomes = "efast", verbose = FALSE)
fit
}
#> <sdt_behaviour_model> lme4 | matched frame (coverage >= 12)
#> 8 cards x 12 groups in 2 blocks = 96 rows (of 96)
#> 1 outcome modelled in 0.1s:
#> outcome prev sd_group sd_block
#> efast 0.458 1.132 0.000