| Title: | Mass-Casualty Incident Simulation and Machine Learning Workbench |
|---|---|
| Description: | A generic framework for mass-casualty incident tabletop exercise simulation, decision-path analysis, and machine learning. Provides a discrete-event patient-flow engine, a tidy data model for scenarios, patient cards and decision paths, synthetic cohort generation, supervised triage and therapy-concept classification spanning interpretable and black-box models, sequence analysis of participant decision paths, and a model-agnostic explainability layer. An interactive 'shiny' workbench ties the pieces together for scenario building, simulation, model training, comparison and visualisation. |
| Authors: | Raban Heller [aut, cre] (ORCID: <https://orcid.org/0000-0001-8006-9742>), Gerhard Achatz [aut] |
| Maintainer: | Raban Heller <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.0.0.9000 |
| Built: | 2026-11-03 18:19:02 UTC |
| Source: | https://github.com/CTTIR/sandtablR |
Returns the scenario's casualty cohort. Provided so a scenario can be piped straight into any function that expects a data frame.
## S3 method for class 'scenario' as_tibble(x, ...)## S3 method for class 'scenario' as_tibble(x, ...)
x |
A |
... |
These dots are for future extensions and must be empty. |
The cohort tibble held by the scenario.
sc <- sdt_scenario(sdt_synthetic_cohort(n = 5, seed = 1)) tibble::as_tibble(sc)sc <- sdt_scenario(sdt_synthetic_cohort(n = 5, seed = 1)) tibble::as_tibble(sc)
The ggplot2::autoplot() method for a scenario; dispatches to
sdt_plot_triage_mix().
## S3 method for class 'scenario' autoplot(object, ...)## S3 method for class 'scenario' autoplot(object, ...)
object |
A |
... |
These dots are for future extensions and must be empty. |
A ggplot2 object.
sc <- sdt_scenario(sdt_synthetic_cohort(n = 20, seed = 1)) ggplot2::autoplot(sc)sc <- sdt_scenario(sdt_synthetic_cohort(n = 20, seed = 1)) ggplot2::autoplot(sc)
Renders a patient_card as a single-line character summary. print() shows
exactly this string, so the two cannot drift apart.
## S3 method for class 'patient_card' format(x, ...)## S3 method for class 'patient_card' format(x, ...)
x |
A |
... |
These dots are for future extensions and must be empty. |
A length-1 character vector.
card <- sdt_patient_card( "P01", 34, "m", 4L, TRUE, "thorax", "red", "DCS", 1L ) format(card)card <- sdt_patient_card( "P01", 34, "m", 4L, TRUE, "thorax", "red", "DCS", 1L ) format(card)
Scores new casualties with an sdt_fit() model. The return is always a
tibble, whatever the engine, so downstream code does not have to branch on
which model produced it.
## S3 method for class 'sdt_model' predict(object, new_data, ...)## S3 method for class 'sdt_model' predict(object, new_data, ...)
object |
An |
new_data |
A data frame of casualties to score. |
... |
Passed to the underlying |
A tibble::tibble() with one row per row of new_data.
sdt_predictions() for the held-out predictions the model's own
metrics were computed from.
# See ?sdt_fit for a runnable example. sdt_engines()# See ?sdt_fit for a runnable example. sdt_engines()
Simulates the sighting category and therapy concept a participant assigns to each casualty, using the published per-category hit rates. Where the participant is wrong, the miscall is drawn from the neighbouring categories, since confusing red with yellow is far more likely than confusing red with green.
sdt_add_decisions(cohort, hit = NULL, therapy_hit = 0.75, seed = NULL)sdt_add_decisions(cohort, hit = NULL, therapy_hit = 0.75, seed = NULL)
cohort |
A |
hit |
Named numeric vector of per-category hit rates. Defaults to the
published |
therapy_hit |
Scalar probability that the therapy concept is called correctly. |
seed |
Optional integer seed, applied with |
The cohort with two added columns: called_triage and
called_therapy, the participant's calls.
sdt_consistency_score() to score the calls against the truth.
co <- sdt_synthetic_cohort(n = 200, seed = 1) called <- sdt_add_decisions(co, seed = 2) sdt_consistency_score(called$triage, called$called_triage) # Hit rate per true category, against the published targets. table(called$triage, called$called_triage)co <- sdt_synthetic_cohort(n = 200, seed = 1) called <- sdt_add_decisions(co, seed = 2) sdt_consistency_score(called$triage, called$called_triage) # Hit rate per true category, against the published targets. table(called$triage, called$called_triage)
Returns a fully wired shiny::shinyApp() object: use it to deploy the
workbench, to mount it behind a router, or to run it yourself.
sdt_app()sdt_app()
Prefer this to assembling sdt_app_ui() and sdt_app_server() by hand.
The package serves its own logo from an addResourcePath() mount, and that
registration is process-local: an app object built in one process and
served from another registers nothing in the process that answers the
request. This constructor mounts the assets from onStart, which runs in
the serving process before it takes any request.
A shiny.appobj.
sdt_run_app() to launch it, sdt_app_ui() and
sdt_app_server() for the pieces.
if (requireNamespace("shiny", quietly = TRUE) && requireNamespace("bslib", quietly = TRUE)) { app <- sdt_app() class(app) }if (requireNamespace("shiny", quietly = TRUE) && requireNamespace("bslib", quietly = TRUE)) { app <- sdt_app() class(app) }
All six modules share one shiny::reactiveValues() holding the scenario,
the game run, the fitted models and the leaderboard. Nothing is passed
between modules by any other route, so there is one place to look when the
app and the console disagree.
sdt_app_server(input, output, session)sdt_app_server(input, output, session)
input, output, session
|
Supplied by shiny. |
Invisible NULL, called for its side effects.
# Driven headlessly in the package's own tests: # shiny::testServer(sdt_app_server, { session$setInputs() }) sdt_app_ui# Driven headlessly in the package's own tests: # shiny::testServer(sdt_app_server, { session$setInputs() }) sdt_app_ui
User interface for the sandtablR workbench
sdt_app_ui()sdt_app_ui()
A shiny UI definition.
sdt_run_app(), sdt_app_server().
if (requireNamespace("shiny", quietly = TRUE) && requireNamespace("bslib", quietly = TRUE)) { ui <- sdt_app_ui() class(ui) }if (requireNamespace("shiny", quietly = TRUE) && requireNamespace("bslib", quietly = TRUE)) { ui <- sdt_app_ui() class(ui) }
Joins the gold-standard codebook onto a cohort, or onto any frame carrying a
card id, and replaces the named columns with the deck's own values. The
replaced columns are kept as <col>_original, so the provenance is not lost
and the inference that was there before can still be scored against the
truth that replaced it.
sdt_apply_gold_standard( cohort, gold, on = "card", replace = c("triage", "age", "sex"), keep_original = TRUE )sdt_apply_gold_standard( cohort, gold, on = "card", replace = c("triage", "age", "sex"), keep_original = TRUE )
cohort |
A |
gold |
An |
on |
Name of the card id column in |
replace |
Which columns to overwrite: any of |
keep_original |
Keep each replaced column as |
In the TDSC corpus the three replaceable columns start out as a circular
surrogate (triage, the inter-team consensus call), a constant
(age) and an inference from the log's gendered wording (sex). After this
function they are the card's own values, and triage_source reads "gold"
so a downstream result can say which label it routed on. The engine routes
on triage, so which label sits there decides which question a simulation
answers.
cohort with the named columns replaced, the originals kept when
asked for, and a triage_source column reading "gold". A cohort comes
back a validated cohort; anything else comes back a
tibble::tibble(). The gold_applied attribute records what was done.
triage_source names where the label sitting in triage came from, so it
reads "gold" only when triage was among the columns replaced. Ask for a
subset that leaves triage alone and any existing source label is left
alone with it: a surrogate must never end up wearing the truth's label.
sdt_read_gold_standard(), sdt_triage_accuracy().
## Not run: gold <- sdt_read_gold_standard("Patienten Codebook.xlsx") truth <- sdt_apply_gold_standard(pooled, gold) mean(truth$triage == truth$triage_original) ## End(Not run)## Not run: gold <- sdt_read_gold_standard("Patienten Codebook.xlsx") truth <- sdt_apply_gold_standard(pooled, gold) mean(truth$triage == truth$triage_original) ## End(Not run)
Turns board-area names into the labels used on figures and in the
workbench, so a legend never shows a column name such as "icu_imc".
Anything unrecognised is passed through unchanged rather than becoming
NA, which keeps the function total over user-supplied vocabularies.
sdt_area_labels(areas = sdt_board_areas())sdt_area_labels(areas = sdt_board_areas())
areas |
Character vector of board areas. Defaults to
|
A character vector of labels, the same length as areas.
sdt_board_areas(), and summary.game_run(), whose
reference = TRUE folds the board onto the published reporting
locations.
sdt_area_labels() sdt_area_labels(c("or", "morgue", "nowhere"))sdt_area_labels() sdt_area_labels(c("or", "morgue", "nowhere"))
Turns each row of a cohort back into a patient_card. The inverse
of stacking cards into a cohort; useful when code wants to work with one
casualty at a time.
sdt_as_patient_cards(x)sdt_as_patient_cards(x)
x |
A |
A list of patient_card objects, named by casualty id.
co <- sdt_cohort(sdt_synthetic_cohort(n = 3, seed = 1)) cards <- sdt_as_patient_cards(co) cards[[1]]co <- sdt_cohort(sdt_synthetic_cohort(n = 3, seed = 1)) cards <- sdt_as_patient_cards(co) cards[[1]]
Coerces a data frame of casualties into a cohort, a tibble::tibble()
subclass carrying the column contract shared by sdt_patient_card() and
sdt_scenario(). Validation is vectorised: every column is checked in one
pass and all offending rows are reported, rather than aborting on the
first bad one.
sdt_cohort(data, ...)sdt_cohort(data, ...)
data |
A data frame with the columns produced by
|
... |
These dots are for future extensions and must be empty. |
A cohort object: a tibble subclass with the contract columns
first, ordered by arrival_order. A zero-row input yields a zero-row
cohort with the correct column types.
validate_cohort() to check an existing cohort,
sdt_as_patient_cards() to explode one into cards.
co <- sdt_cohort(sdt_synthetic_cohort(n = 5, seed = 1)) co # Every bad row is named at once, not just the first. bad <- sdt_synthetic_cohort(n = 4, seed = 1) bad$triage[c(2, 4)] <- "purple" try(sdt_cohort(bad))co <- sdt_cohort(sdt_synthetic_cohort(n = 5, seed = 1)) co # Every bad row is named at once, not just the first. bad <- sdt_synthetic_cohort(n = 4, seed = 1) bad$triage[c(2, 4)] <- "purple" try(sdt_cohort(bad))
Cohen's kappa between two sets of labels for the same units: a participant's calls against the card's true category, one participant against another, or a model's predictions across a repeated scenario. Kappa rather than raw agreement, because on a cohort that is 44% red, two people who both guess "red" every time agree 44% of the time while sharing no judgement at all.
sdt_consistency_score(truth, estimate, levels = NULL)sdt_consistency_score(truth, estimate, levels = NULL)
truth |
Reference labels. |
estimate |
Labels to compare against |
levels |
Optional level set. Defaults to the union of both inputs, so a level nobody used does not distort the expected agreement. |
A one-row tibble::tibble() with .metric, .estimator,
.estimate and n.
if (requireNamespace("yardstick", quietly = TRUE)) { truth <- c("red", "red", "yellow", "green", "green") called <- c("red", "yellow", "yellow", "green", "green") sdt_consistency_score(truth, called) }if (requireNamespace("yardstick", quietly = TRUE)) { truth <- c("red", "red", "yellow", "green", "green") called <- c("red", "yellow", "yellow", "green", "green") sdt_consistency_score(truth, called) }
Collapses a sdt_participants register to one row per course, keyed by
the JSON file name so it joins straight onto the corpus, and carries the
composition covariates a model can use.
sdt_course_composition( x, impute = c("flag", "median", "none"), min_participants = 3L )sdt_course_composition( x, impute = c("flag", "median", "none"), min_participants = 3L )
x |
A |
impute |
How to handle a covariate a course has no observation for:
|
min_participants |
Below this many recorded participants a course is marked as too small to average. |
A sdt_course_composition object: a tibble with one row per
course, including courses with no participants at all, which come through
with has_participants = FALSE, n_participants = 0 and every covariate
NA or imputed-and-flagged. A course whose sheet was not read carries
sheet_read = FALSE and n_participants = NA, so a sheets = subset
never masquerades as a set of cancelled courses.
The register has no group, team or "Gruppe" column, so a participant cannot
be placed in group A rather than group B. Composition is therefore a
block-level covariate: in y ~ card + (1 | block) + (1 | group) it can
compete with the session random effect and can never explain the
team random effect. Every group inside one course gets the same row.
Fitting on this table is a between-course regression with as many points as
there are courses, and a covariate that barely varies between courses
cannot explain anything at all — check the spread before using one.
Every covariate comes with three companions: n_<covariate>, the number of
participants the course mean was taken over; n_imputed_<covariate>, how
many of those numbers came from a participant-level fill rather than an
answer somebody wrote down (only the academic ladder has one by default);
and <covariate>_missing, whether the course value itself was filled. The
two count columns always tell the truth, whatever impute is set to.
impute = "flag" (the default) fills a missing covariate with the
corpus-wide value — the pooled mean over all participants, not the
mean of course means — and sets the indicator. A model can then carry
"this was missing" as information instead of pretending it was observed.
impute = "median" fills with the between-course median and does not
set the indicator. It exists because a downstream pipeline can refuse a
column that is perfectly collinear with has_participants, which is
exactly what the indicator is when the only empty courses are the ones
with no participants; dropping the indicator then buys rank instead of
information. It is not the default because it makes an imputed value
indistinguishable from an observed one, which is the thing this module
exists to prevent. Only n_<covariate> still gives it away.
impute = "none" leaves NA and still sets the indicator.
A course with fewer than min_participants recorded people is marked in
below_min_participants. Its observed value is kept, not overwritten:
the flag is what stops it being read as if it were solid, and
n_participants is there to weight or filter on. A share computed from two
people is not missing, it is noisy, and those are different problems.
sdt_read_participants(), sdt_participant_summary().
## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") comp <- sdt_course_composition(p) comp[, c("short_course_id", "n_participants", "share_consultant")] # How much does each covariate actually vary between courses? vapply(comp[, c("share_female", "share_consultant")], stats::sd, numeric(1), na.rm = TRUE) ## End(Not run)## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") comp <- sdt_course_composition(p) comp[, c("short_course_id", "n_participants", "share_consultant")] # How much does each covariate actually vary between courses? vapply(comp[, c("share_female", "share_consultant")], stats::sd, numeric(1), na.rm = TRUE) ## End(Not run)
Quantifies how consistently the same casualty is routed across
replications. Two measures are offered, both returning a score in [0, 1]
where 1 is perfectly consistent.
sdt_decision_consistency(run, method = c("om", "entropy"))sdt_decision_consistency(run, method = c("om", "entropy"))
run |
A |
method |
|
"om" compares each casualty's paths with one another by optimal matching:
the mean pairwise edit distance between its replication paths, normalised
by the longer of each pair. This is the measure that answers "does this
casualty get handled the same way twice?".
"entropy" scores each path on its own, as the normalised Shannon entropy
of the time it spent in each area. A casualty that sat in one area all game
scores 1; one moved through many areas scores lower. That describes the
shape of a single trajectory rather than agreement between trajectories, so
it is meaningful even with a single replication.
A tibble::tibble() with one row per casualty: id, n_paths
(how many replication paths contributed), n_distinct (how many of them
differ), and consistency. With method = "om" and a single
replication there is nothing to compare, so consistency is NA.
sdt_decision_paths(), sdt_seq_cluster().
co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 4, seed = 1, verbose = FALSE) sdt_decision_consistency(run) sdt_decision_consistency(run, method = "entropy")co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 4, seed = 1, verbose = FALSE) sdt_decision_consistency(run) sdt_decision_consistency(run, method = "entropy")
Extracts, for each casualty in each replication, the ordered sequence of board areas visited.
sdt_decision_paths(run, format = c("long", "wide"))sdt_decision_paths(run, format = c("long", "wide"))
run |
A |
format |
Every casualty appears in every replication. One the game was called before
reaching gets a single-step path of |
A tibble::tibble(). Long: replication, id, step, area,
start, end, duration. Wide: replication, id, then step_1 to
step_k, padded with NA for casualties whose path was shorter.
sdt_seqdef(), sdt_decision_consistency(),
sdt_plot_sequences().
co <- sdt_synthetic_cohort(n = 10, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) sdt_decision_paths(run) sdt_decision_paths(run, format = "wide")co <- sdt_synthetic_cohort(n = 10, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) sdt_decision_paths(run) sdt_decision_paths(run, format = "wide")
A descriptive table over a sdt_participants register, a
sdt_course_composition table, or any data frame. Every variable reports
its counts, its missingness and the summary its type deserves – counts and
percents for a categorical variable, mean, SD, median, IQR and range for a
numeric one – optionally split by a grouping column.
sdt_describe( x, by = NULL, vars = NULL, na_report = c("row", "column", "footnote"), digits = 2 ) ## S3 method for class 'sdt_describe' format(x, ..., max_groups = 8L, max_levels = 12L) ## S3 method for class 'sdt_describe' print(x, ..., max_groups = 8L, max_levels = 12L)sdt_describe( x, by = NULL, vars = NULL, na_report = c("row", "column", "footnote"), digits = 2 ) ## S3 method for class 'sdt_describe' format(x, ..., max_groups = 8L, max_levels = 12L) ## S3 method for class 'sdt_describe' print(x, ..., max_groups = 8L, max_levels = 12L)
x |
A |
by |
Optional single column name to split by. |
vars |
Optional character vector of columns to describe. Defaults to a curated set for the two classes this package produces, and to every non-indicator column otherwise. |
na_report |
Where to surface missingness: |
digits |
Decimal places for the printed statistics. Percentages use one fewer, since a percent already carries two significant figures more than the proportion it comes from. The returned tibble is never rounded. |
... |
These dots are for future extensions and must be empty. |
max_groups |
Groups to print before the rest are summarised in one line. The object keeps every group either way. |
max_levels |
Levels to print per categorical variable. |
A sdt_describe object: a tibble::tibble() subclass with one row
per group, variable and level. Two families of counts share every row and
are named apart so they cannot be confused. n, n_level_observed and
n_level_imputed belong to the row – to one level of one categorical
variable, or to the values of one numeric variable. n_observed,
n_imputed, n_missing, n_valid and n_records belong to the
variable, and are repeated on each of its rows, so that
n_observed + n_imputed + n_missing == n_records holds on every row of
the table. The three percents, pct_missing and the numeric statistics
complete it. tibble::as_tibble() returns it bare;
sdt_describe_render() returns the display-ready shape.
A categorical level carries three shares, and all three are in the returned tibble whatever the report style:
pct_observed – over answered rows only. Its denominator excludes both
missing rows and filled ones.
pct_valid – over rows that carry a value. Its denominator excludes
missing rows and includes filled ones.
pct_total – over every row in the group. Its denominator includes
everything, missing rows included.
pct_valid and pct_total are the two a reader usually means by "the
percent", and they differ exactly when something is missing. pct_observed
differs from pct_valid exactly when something was imputed.
na_report decides where missingness is surfaced, never whether it is:
"row" – each categorical variable gains a (missing) row, and each
numeric variable a missing line of its own.
"column" – the missing count and percent appear beside every row.
"footnote" – they are collected under each block, complete variables
named as such so silence is never ambiguous.
n_missing and pct_missing are columns of the returned tibble in all
three cases.
Where the input carries indicators – .missing_<field> and
.imputed_<field> from sdt_read_participants(), <covariate>_missing
and n_<covariate> from sdt_course_composition() – each row is placed
in exactly one of three states: observed, imputed or missing. Imputed rows
are counted separately, never as observations, and numeric variables carry
mean_observed, sd_observed and median_observed alongside the
all-values statistics so both are visible at once.
For a variable with a fill rule, n_imputed + n_missing is the number of
rows nobody answered: some of them were filled and some were not, and the
two are reported apart rather than added up for the reader. That is why
title_level reports no missing values and 99 imputed ones on the TDSC
register – an empty title box is filled with none, and the fill is
visible in every share it touches.
A value that is present in a row whose own missing indicator is set is not
an observation. It is counted as imputed when an imputation indicator or a
zero denominator (n_<covariate> of 0) says it was filled, and as
missing otherwise. That zero-denominator rule is what catches
sdt_course_composition(impute = "median"), which fills without flagging:
the fill is reported as imputed anyway, and named as unflagged.
The participant register has no group column, so a composition descriptive split by course is a between-course description with as many points as there are courses. There is no split by group to be had.
sdt_describe_participants() for the participant defaults,
sdt_participant_summary() for the participant-only summary this
generalises, sdt_course_composition() for the imputing half of the
policy.
d <- data.frame( grade = c("consultant", "resident", "consultant", NA), years = c(11, 2, 7, NA), .missing_grade = c(FALSE, FALSE, FALSE, TRUE), .imputed_grade = c(FALSE, FALSE, FALSE, FALSE) ) sdt_describe(d, vars = c("grade", "years")) tibble::as_tibble(sdt_describe(d, vars = "grade"))d <- data.frame( grade = c("consultant", "resident", "consultant", NA), years = c(11, 2, 7, NA), .missing_grade = c(FALSE, FALSE, FALSE, TRUE), .imputed_grade = c(FALSE, FALSE, FALSE, FALSE) ) sdt_describe(d, vars = c("grade", "years")) tibble::as_tibble(sdt_describe(d, vars = "grade"))
sdt_describe() with the defaults a sdt_participants register wants:
split by course, and reporting the normalised covariates rather than the
raw German free text they were derived from.
sdt_describe_participants(x, by = "short_course_id", ...)sdt_describe_participants(x, by = "short_course_id", ...)
x |
A |
by |
Column to split by. Defaults to |
... |
Passed to |
Courses with no linked participants have no rows in the register, so they
cannot appear as groups. They are named in the printed header instead,
read from the course_index attribute, so their absence is stated rather
than left for the reader to notice.
A sdt_describe object.
sdt_describe(), sdt_read_participants().
## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") sdt_describe_participants(p) sdt_describe_participants(p, by = NULL, na_report = "footnote") ## End(Not run)## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") sdt_describe_participants(p) sdt_describe_participants(p, by = NULL, na_report = "footnote") ## End(Not run)
Renders a sdt_describe() object as a flat table of character columns,
one row per level or statistic, ready for knitr::kable(), DT or any
other table renderer. The numbers are formatted with the object's digits;
the object itself keeps them unrounded.
sdt_describe_render(x)sdt_describe_render(x)
x |
A |
Missingness follows the object's na_report: a (missing) row, a pair of
missing columns, or a footnote. The legend naming each percent's
denominator is always the first footnote, so a rendered table can never
arrive without one.
A tibble::tibble() of character columns with a footnotes
attribute.
d <- data.frame(grade = c("consultant", "resident", NA), years = c(9, 3, 5)) sdt_describe_render(sdt_describe(d))d <- data.frame(grade = c("consultant", "resident", NA), years = c(9, 3, 5)) sdt_describe_render(sdt_describe(d))
Lists the supported engines, the package each needs, whether that package is installed, and how readable the fitted model is.
sdt_engines()sdt_engines()
class_box grades readability by where an explanation comes from:
"glass" when the fitted model can simply be read, "grey" when its own
structure yields exact per-feature attributions, "black" when attributions
come only from perturbing the inputs, and "closed" when the model learns
its own representation first, so a per-input attribution no longer describes
what the model reasons in. Gradient boosting sits in the middle rather than
with the black boxes, which is where a two-way split would put it.
A tibble::tibble() with columns engine, package, class_box
and installed.
sdt_engines()sdt_engines()
Compares simulated triage calls against observed ones per true category, and reports two ratios for every statistic: the level and the between-team standard deviation, each as observed over simulated, each judged inside the same 0.90-1.15 band the rest of the package uses. Getting the average error rate right while making every team err identically is the failure this function exists to catch, so the level alone is never enough.
sdt_error_fidelity( sim, observed = NULL, truth = NULL, estimate = NULL, group = NULL, card = NULL, cards = NULL, min_coverage = NULL, n_boot = 200L, seed = NULL )sdt_error_fidelity( sim, observed = NULL, truth = NULL, estimate = NULL, group = NULL, card = NULL, cards = NULL, min_coverage = NULL, n_boot = 200L, seed = NULL )
sim |
Simulated calls: an |
observed |
The observed calls, in the same shape. Optional when |
truth, estimate, group, card
|
Column names. |
cards |
Optional character vector fixing the matched frame's cards.
|
min_coverage |
Minimum number of observed teams a card must be seen
by to enter the matched frame. |
n_boot |
Bootstrap replicates for the intervals. |
seed |
Optional integer seed, for a reproducible bootstrap. |
An S3 object of class sdt_error_fidelity, a
tibble with one row per (statistic, measure):
statistic, category, quantity, measure ("level" or "sd"),
observed, simulated, ratio, ci_lower, ci_upper, verdict,
control, n_obs, n_sim, location_z, overlap, the four percentile
columns, reason, n_cards, n_obs_rows, n_sim_rows and
n_sim_reps. Carries band, location_tol, frame, structural,
group_rates and n_boot attributes. The column set is a superset of
what sdt_plot_fidelity() needs, so the table can be drawn with it.
sdt_fidelity()
sdt_fidelity() scores unconditional per-group rates, and enforces
composition matching on the value: a group is dropped for a statistic
unless its non-missing cells cover every shared card. A conditional
statistic – the hit rate among the red cards – is undefined on every card
that is not red, so under that rule every group is dropped on both sides.
Handed such a statistic, sdt_fidelity() does not fail loudly; it returns
the row with n_obs = 0, n_obs_rows = 0, an NA ratio and the reason
"not evaluable", which is the correct answer to the question it was
asked and no answer at all to this one. The conditional case also needs a
different denominator per category, a per-category composition check, and
two ratios per statistic rather than one. What the two share is the
verdict: the band and the location tolerance are read from
sdt_fidelity()'s own helpers, so they cannot drift apart.
For each true category, and for the pooled "overall" row, the hit rate,
the over-triage rate and the under-triage rate. Direction is the clinically
meaningful part: over-triage spends a scarce resource, under-triage can
cost a life, and a single accuracy figure hides which one a team is doing.
Two pairs are zero by construction and are excluded rather than failed:
over-triage on the most severe category and under-triage on the least,
since there is nothing above red or below green to move to. They are listed
in the structural attribute. Note also that for those same two categories
the remaining error rate is the exact complement of the hit rate, so
red under and green over carry the same information as red hit and
green hit; they are reported because the direction of error is what a
reader is looking for, not because they are independent evidence.
Real play is truncated by the clock, so observed teams saw different
subsets of the deck while every simulated team is dealt all of it. Keeping
only the teams that saw the whole intersection would therefore keep the
handful that finished, so the card set is chosen the way
sdt_fit_behaviour() chooses it: the threshold that maximises cards times
complete teams, with ties going to the frame that keeps more teams. Pass
cards to fix the set by hand, or min_coverage to fix the threshold.
The two sides must then agree about which true category each kept card carries. A category left with fewer than three cards is warned about: a team's rate there can take only a handful of values, so its between-team SD is as much a property of the deck as of the teams.
sdt_simulate_triage(), sdt_fidelity(),
sdt_triage_accuracy().
set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { fit <- sdt_fit_triage_error(d, verbose = FALSE) sim <- sdt_simulate_triage(fit, n_groups = 20, seed = 1) sdt_error_fidelity(sim, n_boot = 50, seed = 1) }set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { fit <- sdt_fit_triage_error(d, verbose = FALSE) sim <- sdt_simulate_triage(fit, n_groups = 20, seed = 1) sdt_error_fidelity(sim, n_boot = 50, seed = 1) }
Wraps a fitted sdt_fit() model in a DALEX explainer, so permutation
importance, dependence profiles and per-casualty attributions all work off
one object.
sdt_explain(model, data = NULL, y = NULL)sdt_explain(model, data = NULL, y = NULL)
model |
An |
data |
Data to explain on. Defaults to the held-out half of the model's own split, which is the right choice: explaining a model on the rows it was fitted to flatters it. |
y |
Optional true outcome vector; taken from |
A DALEX explainer.
sdt_importance(), sdt_profile(), sdt_shap().
# See ?sdt_importance for a runnable example. sdt_engines()# See ?sdt_importance for a runnable example. sdt_engines()
Writes a simulated cohort, the fidelity metrics that judge it, the deck it
was drawn over, a README and a manifest.json into one archive that can be
handed to someone who was not in the room. The archive is the return value,
so the whole function is a one-liner inside a Shiny downloadHandler().
sdt_export_simulation( x, path, format = c("csv", "parquet", "rds"), metrics = NULL, overwrite = FALSE, pseudonymise = TRUE, crosswalk = NULL, allow_git = FALSE, zip = TRUE, verbose = TRUE )sdt_export_simulation( x, path, format = c("csv", "parquet", "rds"), metrics = NULL, overwrite = FALSE, pseudonymise = TRUE, crosswalk = NULL, allow_git = FALSE, zip = TRUE, verbose = TRUE )
x |
A |
path |
Destination. With |
format |
Storage format for the tables: |
metrics |
A fidelity table from |
overwrite |
Overwrite an existing destination? |
pseudonymise |
Replace course identifiers with stable codes? Defaults
to |
crosswalk |
Optional path, outside the bundle, for the pseudonym
crosswalk as CSV. |
allow_git |
Permit a destination inside a git working tree? |
zip |
Write a single archive ( |
verbose |
If |
Invisibly, the written paths: the archive when zip = TRUE, the
member files otherwise. The archive path is always the first element, and
carries the member names in attr(., "members").
simulated-groups.* — one row per simulated group and card.
fidelity-metrics.* — the output of sdt_fidelity(), when supplied.
deck-cards.* — the cards the simulation ranged over.
README.md — generated at write time; carries the caveats.
manifest.json — versions, seed, frame, model terms, random-effect SDs,
skipped outcomes, the privacy scheme, and a SHA-256 of every other file.
The writer aborts rather than doing something quietly regrettable:
an existing destination is never overwritten unless overwrite = TRUE;
a destination inside a git working tree is refused unless
allow_git = TRUE, because synthetic rows that get committed are read as
real data by the next person;
with pseudonymise = TRUE the written bytes are searched for the original
identifiers before the file is closed, and a survivor is an error.
Session identifiers and scenario names in this domain embed hospital, venue
and city names and the exact course date. They are therefore mapped to
S01…Snn, groups to G001…Gnnn and scenarios to SC01…SCnn by
default. The crosswalk is written only if crosswalk names a path, and that
path must lie outside the bundle; otherwise the mapping is discarded when
the function returns and the bundle cannot be re-identified from itself.
sdt_fidelity() for the metrics, sdt_simulate_groups() for the
input.
sim <- tibble::tibble( sim_group = rep(c("sim_01", "sim_02"), each = 2L), card = rep(c("01", "02"), 2L), red = c(TRUE, FALSE, TRUE, TRUE) ) out <- sdt_export_simulation( sim, tempfile("demo-bundle-", fileext = ".zip"), verbose = FALSE ) utils::unzip(out, list = TRUE)$Name # In Shiny, the handler's `file` is the archive path: # downloadHandler( # filename = function() "simulation-bundle.zip", # content = function(file) { # sdt_export_simulation(sim, file, metrics = fid, verbose = FALSE) # } # )sim <- tibble::tibble( sim_group = rep(c("sim_01", "sim_02"), each = 2L), card = rep(c("01", "02"), 2L), red = c(TRUE, FALSE, TRUE, TRUE) ) out <- sdt_export_simulation( sim, tempfile("demo-bundle-", fileext = ".zip"), verbose = FALSE ) utils::unzip(out, list = TRUE)$Name # In Shiny, the handler's `file` is the archive path: # downloadHandler( # filename = function() "simulation-bundle.zip", # content = function(file) { # sdt_export_simulation(sim, file, metrics = fid, verbose = FALSE) # } # )
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.
sdt_fidelity(x, observed = NULL, statistics = NULL, n_boot = 200L, seed = NULL)sdt_fidelity(x, observed = NULL, statistics = NULL, n_boot = 200L, seed = NULL)
x |
The simulated groups: an |
observed |
The observed groups, in the same shape. Optional when |
statistics |
Which statistics to score. |
n_boot |
Bootstrap replicates for the interval on |
seed |
Optional integer seed, for a reproducible bootstrap. |
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.
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. Inf when the simulator produces no
between-group spread at all, which is a real and reportable result.
ci_lower, ci_upper: a percentile bootstrap interval on ratio,
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; NA below that.
observed_mean, simulated_mean, location_z: the level check.
location_z is 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 the
observed_p10/observed_p90/simulated_p10/simulated_p90 columns 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 and abs(location_z) is at most 0.25. reason names
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".
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.
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.
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.
sdt_plot_fidelity() to draw the result,
sdt_run_behaviour() to score the discrete-event engine on the same
statistics.
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)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)
Trains a supervised classifier predicting either the sighting category or the therapy concept of a casualty from its card, through a full tidymodels path: a preprocessing recipe, stratified cross-validation, a tuning grid, and a final fit evaluated on a held-out split.
sdt_fit( data, target = c("triage", "therapy", "called_triage", "called_therapy"), engine = NULL, predictors = NULL, tune = TRUE, grid = 5L, folds = 5L, prop = 0.75, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )sdt_fit( data, target = c("triage", "therapy", "called_triage", "called_therapy"), engine = NULL, predictors = NULL, tune = TRUE, grid = 5L, folds = 5L, prop = 0.75, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )
data |
A cohort or data frame of casualties. |
target |
Outcome to predict. |
engine |
One of |
predictors |
Character vector of predictor columns. Defaults to the card fields, excluding identifiers and every outcome column, so a model for the participant's call cannot simply read the true category. |
tune |
If |
grid |
Number of candidate hyperparameter settings. |
folds |
Number of cross-validation folds, stratified on the target. |
prop |
Proportion of the data used for training in the initial split. |
parallel |
If |
workers |
Number of parallel workers, or |
seed |
Optional integer seed, applied with |
verbose |
If |
The returned object keeps the split, the resamples and the tuning results, not only the fitted workflow, so the numbers reported later can be traced back to what produced them.
An S3 object of class sdt_model with elements workflow,
engine, target, predictors, class_box, split, resamples,
tuning, best and metrics.
sdt_workflowset() to screen every engine at once,
sdt_metrics(), sdt_roc(), sdt_explain().
co <- sdt_synthetic_cohort(n = 200, seed = 3) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { fit <- sdt_fit(co, target = "triage", engine = "tree", tune = FALSE, seed = 1, verbose = FALSE) fit sdt_metrics(fit) }co <- sdt_synthetic_cohort(n = 200, seed = 3) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { fit <- sdt_fit(co, target = "triage", engine = "tree", tune = FALSE, seed = 1, verbose = FALSE) fit sdt_metrics(fit) }
Fits one binomial mixed model per outcome to observed (group, card) decision episodes:
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 )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 )
data |
A data frame of decision episodes, one row per (group, card). |
outcomes |
Character vector of outcome names, or |
group, block, card
|
Column names holding the team identifier, the
session (block) the team played in, and the patient card. |
engine |
|
nAGQ |
Quadrature points for the lme4 engine. |
frame |
|
min_coverage |
Minimum number of groups a card must be seen by to enter
the matched frame. |
block_data |
Optional data frame with one row per block, keyed by the
|
block_covariates |
Optional character vector naming the columns of
|
verbose |
If |
y ~ card + (1 | block) + (1 | group)
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.
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 carries
composition (the centring and scaling used) and block_variance
(the block SD before and after, per outcome).
skippedOne row per outcome not modelled, with the reason.
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.
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
y ~ card + <covariates> + (1 | block) + (1 | group)
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 = 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.
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.
sdt_simulate_groups() to draw new teams from the fit.
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 }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 }
Models the called sighting category conditional on the true one, so the
object being fitted is a row of the confusion matrix rather than a marginal
rate. Where sdt_fit_behaviour() answers "how often does a team call red",
this answers "given that this casualty is red, what does a team call, and
how far do teams differ in getting it wrong".
sdt_fit_triage_error( data, truth = "triage", estimate = "called_triage", group = "group_id", block = "session_id", card = "card", engine = "lme4", verbose = TRUE, card_effect = c(hit = "fixed", split = "none"), frame = c("all", "matched"), min_coverage = NULL, joint = FALSE )sdt_fit_triage_error( data, truth = "triage", estimate = "called_triage", group = "group_id", block = "session_id", card = "card", engine = "lme4", verbose = TRUE, card_effect = c(hit = "fixed", split = "none"), frame = c("all", "matched"), min_coverage = NULL, joint = FALSE )
data |
A data frame of decision episodes, one row per (group, card),
carrying both the true and the called category – the shape
|
truth, estimate
|
Columns holding the true and the called sighting
category. Both must hold |
group, block, card
|
Columns holding the team, the session the team
played in, and the patient card. |
engine |
|
verbose |
If |
card_effect |
How the card enters each part: one or two of |
frame |
|
min_coverage |
Passed to the matched frame; |
joint |
If |
An S3 object of class sdt_triage_error_model:
cellsOne row per (category, part) fitted, with n_obs,
prevalence, the card effect actually used, sd_group, sd_block,
sd_card, the null fit's sd_block_null and aic_null,
delta_aic, chisq, df, p_value, and the singular,
boundary and separated flags.
fits,fits_null
Named lists of fitted models, keyed
"category.part", with and without the team term.
jointNULL, or one row per category with the joint fit's
team correlation, per-part SDs, AIC and why it was rejected.
termsColumn names, card levels, the card-to-truth lookup and the wrong-answer ordering the split refers to.
dataThe frame actually fitted on.
metaCounts, frame, engine, versions, runtime and the observed confusion matrix.
skippedOne row per cell not modelled, with the reason.
For each true category k the response has three levels and therefore two
free logits. They are fitted as a sequential pair:
hit – P(called = k | true = k), over every row with truth k.
split – P(called = w1 | called != k, true = k), over the error
rows only, where w1 is the more severe of the two remaining categories.
Each is a binomial mixed model
y ~ card + (1 | block) + (1 | group)
This is an exact reparameterisation of the three-category multinomial,
not an approximation to it: the multinomial likelihood factorises into the
two binomial likelihoods, so fitting them separately fits the multinomial
with the team random effects of the two logits held independent. The single
parameter the decomposition gives up is the correlation between a team's
accuracy and its error direction, and joint = TRUE estimates exactly that
(see below).
Cards inside one true category are not alike. On the validation corpus red card 14 was called correctly by 26% of teams and 15 of the 30 red cards by every team. Removing the card term does not shrink the team effect, it erases it: the fitted team SD goes to exactly 0 for red and for yellow, against 0.544 and 0.639 with the card term, and the AIC is 473 and 137 worse. Every group saw nearly the same deck, so the spread of raw hit rates across teams is narrower than binomial noise around a single pooled probability, and a model with one probability per category can only account for that by setting the team variance to zero.
card_effect therefore defaults to "fixed" for the hit part. It takes
one value for both parts, or two – c("fixed", "none") sets the hit part
then the split part – or a named vector such as
c(hit = "fixed", split = "random").
The split part defaults to "none", and that is measured rather than
assumed. Its cells hold few error rows with a small minority (176, 124 and
529 rows with 10, 24 and 38 minority calls on this corpus); a card-fixed
split runs the yellow team SD up to 41 on the logit scale, is worse by AIC
than no card term for red, and leaves any card that no team ever miscalled
with no coefficient at all. It also costs little, because for red both
wrong answers are under-triage and for green both are over-triage: the
split moves cells inside the confusion matrix without moving the
over- and under-triage rates.
A card term is attempted only where there are at least as many minority
events as card levels, and a fit is rejected if it is rank deficient, fails
to converge, or lands a random-effect SD above 5 on the logit scale –
which is separation, not a tendency. Each rejection demotes the cell one
rung down "fixed", "random", "none" and is recorded in the cell's
demotions. A cell that runs out of rungs, or that is constant, or that
has too few events or groups, appears in $skipped with its reason and is
never dropped in silence. A fit that lands a zero team variance is kept
and flagged singular: teams not differing is a finding, not a failure.
joint = TRUE stacks a category's two parts and fits them together with an
unstructured team covariance, which is the full multinomial mixed model for
that category. It is a diagnostic: it measures the one parameter the
default gives up, and sdt_simulate_triage() never reads it. Run on the
validation corpus it converged for green only – correlation -0.050, and
a joint AIC equal to the sum of the two separate ones – while red came
back rank deficient with a team correlation of 0.99 and yellow ran its
split SD to 33-40. The full multinomial does not fit reliably at this n,
and where it does fit it says the constraint costs nothing.
Every cell is fitted twice: once as above and once with (1 | group)
removed from exactly the same rows. $cells carries sd_group, the null
fit's AIC, delta_aic and a likelihood-ratio test, so the necessity of the
team term is a measured claim per cell rather than a stated one. The null
fits are kept, and sdt_simulate_triage() can simulate from them with
team_effects = FALSE.
sdt_simulate_triage() to draw new teams from the fit,
sdt_error_fidelity() to score them, sdt_triage_accuracy() for the
observed table.
set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { sdt_fit_triage_error(d, verbose = FALSE) }set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { sdt_fit_triage_error(d, verbose = FALSE) }
How much worse the model gets when each predictor is shuffled. The measure is model-agnostic, so a random forest and a decision tree are ranked on the same scale.
sdt_importance(x, data = NULL, n_permutations = 10L)sdt_importance(x, data = NULL, n_permutations = 10L)
x |
An |
data |
Optional data to explain on, passed to |
n_permutations |
Number of permutation rounds. More is steadier and slower. |
A ggplot2 object.
co <- sdt_add_decisions(sdt_synthetic_cohort(n = 200, seed = 3), seed = 4) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart", "DALEX", "DALEXtra") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { fit <- sdt_fit(co, target = "called_triage", engine = "tree", tune = FALSE, seed = 1, verbose = FALSE) sdt_importance(fit) }co <- sdt_add_decisions(sdt_synthetic_cohort(n = 200, seed = 3), seed = 4) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart", "DALEX", "DALEXtra") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { fit <- sdt_fit(co, target = "called_triage", engine = "tree", tune = FALSE, seed = 1, verbose = FALSE) sdt_importance(fit) }
Area under the ROC curve, accuracy, Cohen's kappa and macro F. Kappa is included deliberately: on a cohort that is 44% red, accuracy alone rewards a model for guessing the majority class.
sdt_metric_set()sdt_metric_set()
A yardstick metric set.
if (requireNamespace("yardstick", quietly = TRUE)) { sdt_metric_set() }if (requireNamespace("yardstick", quietly = TRUE)) { sdt_metric_set() }
Returns the held-out performance of an sdt_fit() model, always with the
same three columns whatever engine produced it.
sdt_metrics(model)sdt_metrics(model)
model |
An |
A tibble::tibble() with columns .metric, .estimator and
.estimate.
# See ?sdt_fit for a runnable example. sdt_metric_set# See ?sdt_fit for a runnable example. sdt_metric_set
The TDSC participant register stores four covariates as German free text.
This function holds the maps that fold that text onto controlled
vocabularies as data, so extending the package for a spelling it has
not seen is one argument rather than a code change: whatever you pass is
utils::modifyList()-merged, recursively, onto the shipped default.
sdt_participant_dictionary( funktion = NULL, titel = NULL, fachbereich = NULL, anrede = NULL )sdt_participant_dictionary( funktion = NULL, titel = NULL, fachbereich = NULL, anrede = NULL )
funktion, titel, fachbereich, anrede
|
Optional named lists merged onto
the corresponding shipped slot. Each slot has a |
The four vocabularies are:
funktion — an ordered seniority ladder,
resident < specialist < consultant < chief
(Assistenzarzt, Facharzt, Oberarzt, Chefarzt), scored 1–4. other
("Andere") is a real answer that sits off the ladder and scores NA;
not_stated ("Nichts angegeben") is a declared non-answer and is
treated as missing, not as a sixth grade.
titel — the academic ladder none < doctorate < habilitation < professor, scored 0–3. Matching is exact on a punctuation- and
case-insensitive key first, then falls back to patterns, where every
rung is tested and the highest match wins. An empty title box is filled
with none when blank_is_none is TRUE — and every filled row is
flagged, see sdt_read_participants().
fachbereich — a controlled specialty list plus a surgical /
non-surgical group. Multi-specialty free text ("Chirurgie,
Unfallchirurgie") is collapsed by the stated rule the primary specialty
is the one mentioned first: the pattern whose match starts earliest in
the string wins.
anrede — salutation to sex. This is a proxy: it records how a
person was addressed on a registration form, not a gender identity.
An S3 object of class sdt_participant_dictionary: a list of four
slots, with print() and format() methods.
sdt_read_participants(), which applies it.
d <- sdt_participant_dictionary() d d$funktion$score # One new spelling, no package patch. d2 <- sdt_participant_dictionary( funktion = list(map = list("leitender oberarzt" = "consultant")) ) d2$funktion$map[["leitender oberarzt"]]d <- sdt_participant_dictionary() d d$funktion$score # One new spelling, no package patch. d2 <- sdt_participant_dictionary( funktion = list(map = list("leitender oberarzt" = "consultant")) ) d2$funktion$map[["leitender oberarzt"]]
Tabulates the normalised participant covariates without silently dropping or filling anything. Every row carries both denominators and both kinds of uncertainty, so a reader cannot see a share without seeing what it rests on:
sdt_participant_summary(x, fields = NULL, by = NULL)sdt_participant_summary(x, fields = NULL, by = NULL)
x |
A |
fields |
Optional character vector of covariates to report. Defaults to every categorical covariate the dictionary normalises. |
by |
Optional column name to report within, typically |
n — rows at this level, filled rows included.
n_filled — how many of those n came from a participant-level fill
rather than from an answer somebody wrote down. Read n - n_filled as
the observed count.
n_records — every row in the group, the denominator of
share_records.
n_observed — rows where the raw field was actually recorded, taken
from the .missing_ indicator, and the denominator of
share_observed, which counts observed rows only.
n_missing, share_missing — what is not there, in the same row as the
shares it is missing from.
This is the honest half of the missingness policy. The imputing half lives
in sdt_course_composition(), which fills and flags.
A tibble with one row per level, carrying n, n_filled,
n_observed, n_records, share_observed, share_records,
n_missing and share_missing.
sdt_read_participants(), sdt_course_composition().
## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") sdt_participant_summary(p) sdt_participant_summary(p, fields = "seniority", by = "short_course_id") ## End(Not run)## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") sdt_participant_summary(p) sdt_participant_summary(p, fields = "seniority", by = "short_course_id") ## End(Not run)
Constructs a single validated patient_card describing one casualty in a
mass-casualty scenario. Fields follow the TDSC tabletop patient-card model.
sdt_patient_card( id, age, sex, life_points, ventilated, injury_region, triage, therapy, arrival_order )sdt_patient_card( id, age, sex, life_points, ventilated, injury_region, triage, therapy, arrival_order )
id |
Character scalar identifier for the casualty. |
age |
Whole number, patient age in years. |
sex |
One of |
life_points |
Whole number in |
ventilated |
Logical; whether the casualty requires ventilation. |
injury_region |
Leading injury region; one of
|
triage |
True sighting category; one of |
therapy |
True therapy concept; one of |
arrival_order |
Whole number giving position in the arrival sequence. |
An S3 object of class patient_card.
sdt_patient_card( id = "P01", age = 34, sex = "m", life_points = 4L, ventilated = TRUE, injury_region = "thorax", triage = "red", therapy = "DCS", arrival_order = 1L )sdt_patient_card( id = "P01", age = 34, sex = "m", life_points = 4L, ventilated = TRUE, injury_region = "thorax", triage = "red", therapy = "DCS", arrival_order = 1L )
The picture the exercise is played on: the nine areas of the published board
laid out as in the source's Fig. 1, with every casualty drawn as a point in
the area it had reached by a given moment. Scrub at across the game and
you watch the casualty stream move over the board, which is the one view
that makes a decision path legible without reading a table.
sdt_plot_board(run, at = NULL, replication = 1L, interactive = FALSE)sdt_plot_board(run, at = NULL, replication = 1L, interactive = FALSE)
run |
A |
at |
Time in minutes to draw. Defaults to the end of the game. |
replication |
Which replication to draw. |
interactive |
If |
A casualty's area at time at is the last area it had entered by then,
which is the same rule summary.game_run() uses. That matters at the end of
the game: someone still being operated on when the clock stops is in
theatre, not back where they came from.
A ggplot2 object.
sdt_plot_flow() for the same event log as a Gantt chart.
run <- sdt_simulate(sdt_reference_scenario(), replications = 1, seed = 1, verbose = FALSE) sdt_plot_board(run) # Halfway through the game. sdt_plot_board(run, at = sdt_reference_horizon() / 2)run <- sdt_simulate(sdt_reference_scenario(), replications = 1, seed = 1, verbose = FALSE) sdt_plot_board(run) # Halfway through the game. sdt_plot_board(run, at = sdt_reference_horizon() / 2)
Draws the observed proportion per reporting location as bars, with the published reference proportion overlaid as a point and the gap drawn as a segment between them. It is the honest version of a calibration claim: the gap is visible rather than asserted.
sdt_plot_end_state(run, version = c("2.0", "1.0"))sdt_plot_end_state(run, version = c("2.0", "1.0"))
run |
A |
version |
Which evaluated version of the exercise to compare against,
|
A ggplot2 object.
summary.game_run(), which produces the underlying tibble.
run <- sdt_simulate(sdt_reference_scenario(), replications = 4, seed = 1, verbose = FALSE) sdt_plot_end_state(run) sdt_plot_end_state(run, version = "1.0")run <- sdt_simulate(sdt_reference_scenario(), replications = 4, seed = 1, verbose = FALSE) sdt_plot_end_state(run) sdt_plot_end_state(run, version = "1.0")
A dot-and-range chart of the dispersion ratios: one row per statistic,
the ratio on the x axis, the passing band shaded, and the bootstrap
interval drawn as a range. Controls carry their own marker because they
are read backwards from the rest – see the "Negative controls" section of
sdt_fidelity().
sdt_plot_fidelity(x)sdt_plot_fidelity(x)
x |
A fidelity table from |
A ratio of Inf, which is what a simulator that produces no between-group
spread at all earns, is drawn as an arrow pinned to the right-hand edge and
labelled, rather than being dropped from the plot or silently rescaling it.
A ggplot2 object.
set.seed(1) cards <- sprintf("c%02d", 1:6) 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] sim <- expand.grid(sim_group = sprintf("s%02d", 1:10), card = cards, stringsAsFactors = FALSE) sim$surgery <- stats::runif(nrow(sim)) < mean(team) sdt_plot_fidelity(sdt_fidelity(sim, obs, n_boot = 50, seed = 1))set.seed(1) cards <- sprintf("c%02d", 1:6) 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] sim <- expand.grid(sim_group = sprintf("s%02d", 1:10), card = cards, stringsAsFactors = FALSE) sim$surgery <- stats::runif(nrow(sim)) < mean(team) sdt_plot_fidelity(sdt_fidelity(sim, obs, n_boot = 50, seed = 1))
A Gantt chart of one replication: each casualty is a row, each board area a coloured bar spanning the time it was there. This is the picture of the event log, and the fastest way to see where a board is congested.
sdt_plot_flow(run, replication = 1L, max_casualties = 40L)sdt_plot_flow(run, replication = 1L, max_casualties = 40L)
run |
A |
replication |
Which replication to draw. |
max_casualties |
Cap on the number of casualties shown, to keep the chart readable. The earliest arrivals are kept. |
A ggplot2 object.
co <- sdt_synthetic_cohort(15, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 1, verbose = FALSE) sdt_plot_flow(run)co <- sdt_synthetic_cohort(15, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 1, verbose = FALSE) sdt_plot_flow(run)
Two standard sequence-analysis views, both drawn with ggplot2 rather than TraMineR's base-graphics plotting, so they compose and theme like every other plot in the package.
sdt_plot_sequences( run, type = c("index", "distribution"), replication = NULL, clusters = NULL )sdt_plot_sequences( run, type = c("index", "distribution"), replication = NULL, clusters = NULL )
run |
A |
type |
|
replication |
Optional replication to restrict to. |
clusters |
Optional cluster assignment from |
"index" draws one row per casualty, coloured by the area occupied at each
position: the shape of individual pathways. "distribution" draws the share
of casualties in each area at each position: the shape of the cohort.
A ggplot2 object.
sdt_seq_cluster(), sdt_decision_paths().
co <- sdt_synthetic_cohort(n = 20, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) sdt_plot_sequences(run) sdt_plot_sequences(run, type = "distribution")co <- sdt_synthetic_cohort(n = 20, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) sdt_plot_sequences(run) sdt_plot_sequences(run, type = "distribution")
Plot the triage mix of a scenario
sdt_plot_triage_mix(scenario)sdt_plot_triage_mix(scenario)
scenario |
A |
A ggplot2 object.
sdt_plot_triage_mix(sdt_scenario(sdt_synthetic_cohort(20, seed = 1)))sdt_plot_triage_mix(sdt_scenario(sdt_synthetic_cohort(20, seed = 1)))
Plot resource utilisation from a game run
sdt_plot_utilisation(run)sdt_plot_utilisation(run)
run |
A |
A ggplot2 object.
co <- sdt_synthetic_cohort(20, seed = 1) sc <- sdt_scenario(co, resources = list(or = 2)) run <- sdt_simulate(sc, replications = 1, verbose = FALSE) sdt_plot_utilisation(run)co <- sdt_synthetic_cohort(20, seed = 1) sc <- sdt_scenario(co, resources = list(or = 2)) run <- sdt_simulate(sc, replications = 1, verbose = FALSE) sdt_plot_utilisation(run)
The predictions sdt_fit() made on the test half of its own split, with
the truth alongside. This is what the metrics were computed from.
sdt_predictions(model)sdt_predictions(model)
model |
An |
A tibble::tibble() with .pred_class, the per-class probability
columns, and truth.
# See ?sdt_fit for a runnable example. sdt_metric_set# See ?sdt_fit for a runnable example. sdt_metric_set
How the model's predicted probability moves as one predictor changes, with the others held at their observed values. Partial dependence averages over the data; accumulated local effects are more trustworthy when predictors are correlated, which on a casualty card they are.
sdt_profile( x, variables = NULL, type = c("partial", "accumulated"), data = NULL )sdt_profile( x, variables = NULL, type = c("partial", "accumulated"), data = NULL )
x |
An |
variables |
Predictors to profile. Defaults to all of them. |
type |
|
data |
Optional data to explain on, passed to |
A ggplot2 object.
# See ?sdt_importance for a runnable example. sdt_engines()# See ?sdt_importance for a runnable example. sdt_engines()
Reads the exercise deck's answer key: one row per patient card, giving the sighting category the card was written to carry, plus the card's recorded sex, age, clinical findings and vital signs.
sdt_read_gold_standard(path, sheet = NULL, verbose = TRUE)sdt_read_gold_standard(path, sheet = NULL, verbose = TRUE)
path |
Path to the codebook workbook ( |
sheet |
Sheet to read. |
verbose |
Report the parsed deck, the guard drops and the cross-checks while reading. |
This is the ground truth the course exports do not contain. An export
records the calls teams made, so any triage number computed from it alone
is inter-team agreement; the field that looks like an answer key is a
round-robin placeholder. Pair this reader with
sdt_apply_gold_standard() and sdt_triage_accuracy() to score calls
against the deck rather than against each other.
An sdt_gold_standard object: a tibble::tibble() subclass with
one row per card and the columns card ("01"–"46"), triage, sex,
age, findings, vitals and sk_raw. The meta attribute carries the
path, the sheet, the guard drops, the legend cross-check and the working
sheet cross-check.
Codebooks acquire duplicate test rows and legend blocks below the data, and a legend written into shifted columns parses as data if it is not caught. Three guards remove them, all by rule rather than by row position, since row positions are the first thing an edited workbook changes:
blank — every contract column is empty.
non_numeric_card — the card id is not a number, which is what catches
both duplicate test rows carrying a letter id and the shifted legend.
card_out_of_range — the card id is a number outside the deck.
How many rows each guard removed is reported when verbose = TRUE and kept
in the meta attribute, so a silent change in the workbook shows up as a
changed count rather than as a changed result.
Two independent statements of the same counts are read back and compared
with the parse: the sheet's own SK I/SK II/SK III legend, whose totals
include the duplicate rows the guards removed, and any earlier working sheet
in the same workbook. Disagreements are reported, never resolved by quietly
preferring one source.
sdt_apply_gold_standard() to write it into a cohort,
sdt_triage_accuracy() to score calls against it.
## Not run: gold <- sdt_read_gold_standard("Patienten Codebook.xlsx") gold attr(gold, "meta")$guards ## End(Not run)## Not run: gold <- sdt_read_gold_standard("Patienten Codebook.xlsx") gold attr(gold, "meta")$guards ## End(Not run)
Reads the participant workbook, binds its per-course sheets, applies
sdt_participant_dictionary() and returns one row per participant record
with the raw and the normalised columns side by side.
sdt_read_participants( path, dictionary = sdt_participant_dictionary(), sheets = NULL, verbose = TRUE )sdt_read_participants( path, dictionary = sdt_participant_dictionary(), sheets = NULL, verbose = TRUE )
path |
Path to the participant workbook ( |
dictionary |
A |
sheets |
Optional character vector of per-course sheet names to
restrict to. |
verbose |
Report row counts, neutralised leaks and per-covariate missingness while reading. |
A sdt_participants object: a tibble::tibble() subclass, one row
per participant record, with attributes course_index (one row per
course, including the empty ones), dictionary and meta.
The register links a person to a course. It carries no group, team or
"Gruppe" column, so a participant cannot be placed in group A rather than
group B. Every group inside one course therefore shares the same
composition row. In the behaviour model
y ~ card + (1 | block) + (1 | group) these covariates are block-level:
they can compete with the session random effect, and can never explain
the team random effect, which has no composition variance to spend.
Treat any composition analysis as a between-course regression on as many
points as there are courses.
Courses with no linked participants are kept, not dropped: the
course_index attribute carries every course in the workbook with its
n_participants and has_participants, so a caller sees the empty courses
rather than inferring them from an absence.
Rows the extractor wrote a diagnostic into instead of an answer are
detected and neutralised to NA before anything is normalised, and a row
with nothing but such a diagnostic is a course placeholder rather than a
person: it is removed from the participant rows and counted in
course_index$n_placeholder_rows.
Two logical column families record what happened to every raw field:
.missing_<field> — the information is not available. For funktion
this includes the declared non-answer "Nichts angegeben", which is
missing information rather than a grade; for weiterbildungsjahre it
includes unparseable free text such as ",".
.imputed_<field> — the normalised value was filled rather than
observed. Only titel has a participant-level fill rule by default
(an empty title box becomes none, see
sdt_participant_dictionary()); the rest are filled, if at all, at
course level by sdt_course_composition(). An imputed value is never
indistinguishable from an observed one.
sdt_course_composition() to aggregate to course level,
sdt_participant_summary() for descriptives that state their
denominators.
## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") p attr(p, "course_index") sdt_course_composition(p) ## End(Not run)## Not run: p <- sdt_read_participants("TDSC_course_participant_relations.xlsx") p attr(p, "course_index") sdt_course_composition(p) ## End(Not run)
Returns the marginal distributions sandtablR calibrates against, read from
the file generated by data-raw/tdsc_reference.R and cached for the
session.
sdt_reference( which = c("all", "published", "design", "defaults", "meta"), version = c("2.0", "1.0") )sdt_reference( which = c("all", "published", "design", "defaults", "meta"), version = c("2.0", "1.0") )
which |
Which part to return: |
version |
Which evaluated version of the exercise the published
marginals should come from: |
The list separates three things that are easy to conflate:
published — marginals attributed to the TDSC tabletop evaluation
(Achatz et al. 2023, doi:10.1007/s00068-022-02217-1). These are what the
package reports and tests as "reference". Both evaluated versions of the
exercise are carried; see version.
design — the exercise as specified in that paper's Methods: duration,
card count, team roles, and the nine-area board. Published, but structural
rather than distributional.
defaults — modelling choices made by sandtablR where the source does
not pin a value (service times, routing probabilities, and so on). They
are not attributed to the paper.
Calibration tests use published only. Mixing the two would test the
package against its own assumptions.
A named list.
sdt_vocabularies for the levels these marginals range over.
sdt_reference("published")$triage_mix sdt_reference("published", version = "1.0")$triage_mix # What the two versions did differently, side by side. rbind( `2.0` = sdt_reference("published", "2.0")$end_state, `1.0` = sdt_reference("published", "1.0")$end_state ) sdt_reference("design")$duration sdt_reference("meta")$doisdt_reference("published")$triage_mix sdt_reference("published", version = "1.0")$triage_mix # What the two versions did differently, side by side. rbind( `2.0` = sdt_reference("published", "2.0")$end_state, `1.0` = sdt_reference("published", "1.0")$end_state ) sdt_reference("design")$duration sdt_reference("meta")$doi
The horizon the engine is calibrated on
sdt_reference_horizon()sdt_reference_horizon()
A single number: the game length in minutes used for calibration.
sdt_reference_horizon()sdt_reference_horizon()
Builds the board configuration that sdt_simulate()'s defaults were tuned
against: a 40-casualty cohort on the resource capacities recorded in
sdt_reference("defaults")$calibration. Running it over the calibrated
horizon reproduces the published end-of-game distribution.
sdt_reference_scenario(seed = NULL)sdt_reference_scenario(seed = NULL)
seed |
Optional integer seed for the cohort. Defaults to the calibration seed. |
Use it as a known-good starting point, and as the fixture the calibration test checks, so that "the model still reproduces the reference" is a claim someone can re-run rather than take on trust.
A scenario object.
sdt_simulate(), summary.game_run().
sc <- sdt_reference_scenario() sc run <- sdt_simulate(sc, replications = 2, seed = 1, verbose = FALSE) summary(run, reference = TRUE)sc <- sdt_reference_scenario() sc run <- sdt_simulate(sc, replications = 2, seed = 1, verbose = FALSE) summary(run, reference = TRUE)
Draws the receiver operating characteristic of an sdt_fit() model on its
held-out split. With more than two classes, one curve per class is drawn
one-versus-all, which is what roc_auc's Hand-Till estimator summarises.
sdt_roc(model)sdt_roc(model)
model |
An |
A ggplot2 object.
# See ?sdt_fit for a runnable example. sdt_metric_set# See ?sdt_fit for a runnable example. sdt_metric_set
Starts the interactive shiny workbench: build a scenario, run the board, annotate decisions, train and compare models, explain them, and analyse decision paths.
sdt_run_app(...)sdt_run_app(...)
... |
Passed to |
The app is assembled by sdt_app() rather than loaded from a directory, so
the modules are ordinary package functions: checked, linted, covered and
testable like everything else.
Invisible NULL. Launches a Shiny application.
sdt_app_ui(), sdt_app_server().
if (interactive()) { sdt_run_app() }if (interactive()) { sdt_run_app() }
Reshapes the event log of a sdt_simulate() run into the same one-row-per
(group, card) frame that sdt_fidelity() scores, so the discrete-event
engine can be held to exactly the same statistics as a fitted behaviour
model. Each replication is one group and each casualty id is one card,
which is what makes the comparison legitimate: groups within a real course
session play the same fixed deck, and so do replications.
sdt_run_behaviour(run, group = "group_id", card = "card")sdt_run_behaviour(run, group = "group_id", card = "card")
run |
A |
group |
Name for the group column in the result. |
card |
Name for the card column in the result. |
A tibble with one row per (replication,
casualty), the two key columns named by group and card, the cohort's
triage and therapy, the casualty's end_area, and one logical column
per behaviour described above.
red, yellow, green, ETC, DCS, TASC and ventilated are read
off the cohort. The engine routes on them, it does not choose them, so
they are identical in every replication and their between-group SD is
exactly zero. That is not a bug in this function; it is the engine's
structure made visible, and sdt_fidelity() will report it as an
infinite dispersion ratio.
surgery, diagnostics, icu, ward, recovery, green_area record
whether the casualty ever entered that board area. These can vary
between replications, through queueing and the horizon.
died is end_area == "morgue", and not_seen is a casualty the game
ended before. The engine has no branch to the morgue, so died is
always FALSE.
The observed counterparts, for a course export, are therapy == "TASC"
and so on for the therapy concepts, called_triage == "red" for the
sighting categories, therapy != "none" for surgery, and the reported
end location for icu. There is deliberately no eFAST or CT column:
the engine has a single radiology area and no modality, so inventing one
would be inventing a comparison.
sdt_fidelity(), summary.game_run().
co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 3, seed = 1, verbose = FALSE) sdt_run_behaviour(run)co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 3, seed = 1, verbose = FALSE) sdt_run_behaviour(run)
Assembles an ordered casualty cohort and the board resource capacities into
a validated scenario object, the central input to the simulation engine.
sdt_scenario(cohort, resources = list(), name = NULL, seed = NULL)sdt_scenario(cohort, resources = list(), name = NULL, seed = NULL)
cohort |
A data frame / tibble of casualties, one row per patient, with
at least the columns produced by |
resources |
Named list of integer capacities for board areas, e.g.
|
name |
Optional scenario name. |
seed |
Optional integer seed recorded in metadata for reproducibility. |
An S3 object of class scenario.
co <- sdt_synthetic_cohort(n = 20, seed = 1) sdt_scenario(co, resources = list(or = 2, icu_imc = 6), name = "concert")co <- sdt_synthetic_cohort(n = 20, seed = 1) sdt_scenario(co, resources = list(or = 2, icu_imc = 6), name = "concert")
Groups casualties by how similar their board pathways are: an optimal matching dissimilarity between state sequences, then partitioning around medoids. What comes out is a small number of characteristic ways through the board, which is usually more useful than a per-casualty path listing.
sdt_seq_cluster(run, k = 3L, method = "OM", indel = 1, sm = "CONSTANT")sdt_seq_cluster(run, k = 3L, method = "OM", indel = 1, sm = "CONSTANT")
run |
A |
k |
Number of clusters. |
method |
Dissimilarity passed to |
indel |
Insertion/deletion cost for optimal matching. |
sm |
Substitution-cost matrix or method. |
A tibble::tibble() with one row per sequence: replication,
id, cluster, and medoid, which flags the sequence representing its
cluster.
sdt_seqdef(), sdt_plot_sequences().
co <- sdt_synthetic_cohort(n = 30, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) if (requireNamespace("TraMineR", quietly = TRUE) && requireNamespace("WeightedCluster", quietly = TRUE)) { sdt_seq_cluster(run, k = 3) }co <- sdt_synthetic_cohort(n = 30, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) if (requireNamespace("TraMineR", quietly = TRUE) && requireNamespace("WeightedCluster", quietly = TRUE)) { sdt_seq_cluster(run, k = 3) }
Converts the decision paths of a game run into a TraMineR::seqdef()
object over the board-area alphabet, ready for sequence analysis. The
alphabet is fixed to sdt_board_areas(), so sequences from different runs
stay comparable even when a run never visits some area.
sdt_seqdef(run, replication = NULL)sdt_seqdef(run, replication = NULL)
run |
A |
replication |
Optional replication to restrict to. |
A stslist object from TraMineR, carrying replication and
casualty attributes recording where each row came from.
sdt_seq_cluster(), sdt_plot_sequences().
co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) if (requireNamespace("TraMineR", quietly = TRUE)) { seqs <- sdt_seqdef(run) dim(seqs) }co <- sdt_synthetic_cohort(n = 12, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) if (requireNamespace("TraMineR", quietly = TRUE)) { seqs <- sdt_seqdef(run) dim(seqs) }
Breaks the model's prediction for a single casualty into per-predictor contributions, using SHAP values. This answers "why this casualty?", as opposed to importance, which answers "what does this model use in general?".
sdt_shap(x, casualty, data = NULL, n_permutations = 10L)sdt_shap(x, casualty, data = NULL, n_permutations = 10L)
x |
An |
casualty |
A one-row data frame, or the |
data |
Optional data to explain on, passed to |
n_permutations |
Number of sampling rounds for the SHAP estimate. |
A ggplot2 object.
sdt_importance(), sdt_profile().
# See ?sdt_importance for a runnable example. sdt_engines()# See ?sdt_importance for a runnable example. sdt_engines()
Executes a discrete-event simulation of a scenario with simmer, routing each casualty through board-area resources according to its sighting category and therapy concept, and returns per-area event records, per-casualty flow records and resource utilisation.
sdt_simulate( scenario, replications = 1L, service_times = list(), horizon = 270, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )sdt_simulate( scenario, replications = 1L, service_times = list(), horizon = 270, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )
scenario |
A |
replications |
Number of independent replications (positive whole number). The replication index is carried in every returned table. |
service_times |
Named list of mean service times in minutes per board area, used as exponential rates. Missing areas fall back to the reference defaults. |
horizon |
Length of the game in minutes. Casualties still in the system
when the clock stops are recorded where they stood, which is what makes
the end-of-game distribution meaningful. |
parallel |
If |
workers |
Number of parallel workers, or |
seed |
Optional integer seed, applied with |
verbose |
If |
Theatre time scales with the therapy concept: abbreviated surgical
care is quickest, damage-control surgery the reference, early total care the
longest. The multipliers live in sdt_reference("defaults")$or_multiplier.
An S3 object of class game_run with elements:
eventsOne row per casualty per board area entered:
replication, id, step, area, start, end, duration,
service_time. A state runs from entering an area to entering the
next, and the last runs to the horizon, so duration is time spent
standing in that area. service_time is the treatment time simmer
actually recorded, and is NA where the casualty was still being
treated at the horizon or was only being held. The two differ
whenever a casualty is finished but waiting for a bed downstream.
arrivalsOne row per casualty per replication, carrying the card fields and total time in system.
resourcesResource utilisation over time.
summary.game_run() for the end-of-game distribution, and
sdt_decision_paths() to turn the event log into state sequences.
co <- sdt_synthetic_cohort(n = 20, seed = 1) sc <- sdt_scenario(co, resources = list(or = 2, icu_imc = 6)) run <- sdt_simulate(sc, replications = 2, seed = 1, verbose = FALSE) run head(run$events)co <- sdt_synthetic_cohort(n = 20, seed = 1) sc <- sdt_scenario(co, resources = list(or = 2, icu_imc = 6)) run <- sdt_simulate(sc, replications = 2, seed = 1, verbose = FALSE) run head(run$events)
Draws teams that were never in the data. For each simulated group and each
modelled outcome the team's own random effect is redrawn from
N(0, sd_group), a session effect is redrawn from N(0, sd_block), and the
outcome is then drawn per card from the fitted card effects:
sdt_simulate_groups( model, n_groups = 20L, cards = NULL, seed = NULL, new_teams = TRUE, nsim = 1L, composition = NULL )sdt_simulate_groups( model, n_groups = 20L, cards = NULL, seed = NULL, new_teams = TRUE, nsim = 1L, composition = NULL )
model |
An |
n_groups |
Number of simulated groups per replicate. |
cards |
Cards to deal to each simulated group. |
seed |
Optional integer seed, applied with |
new_teams |
If |
nsim |
Number of replicates. Replicates are stacked and carry a
|
composition |
Optional block-level covariate values to condition on:
a data frame with one row per course (a slice of
|
p = plogis(card effect + team effect + session effect)
This is deliberately not stats::simulate() on the fitted model with the
fitted grouping structure, which would put the training teams back on the
table. Every simulated group here is a new team playing in a new session.
An S3 object of class sdt_simulated_groups, a
tibble::tibble() with .replicate, sim_group, card and one logical
column per modelled outcome. Carries the fitted model, the call, and a
draws attribute holding the random effects used for each simulated
group.
With new_teams = TRUE (the default) both the group and the block effect
are drawn fresh and independently for every simulated group, so a simulated
group is "a new team in a new session" and its total random effect has
variance sd_group^2 + sd_block^2 – the same marginal spread a real,
previously unseen group has.
With new_teams = FALSE the fitted conditional modes are reused: simulated
groups are the observed teams, with their own session effects, playing the
same cards again. On a model fitted with block covariates their own
course's composition goes back into the linear predictor as well, because
the fitted session modes are residuals with that part already removed.
That is a posterior-predictive check of the fit, not a generator of new
teams. It takes the first n_groups fitted teams in frame
order rather than a random sample, so it is reproducible without a seed;
pass n_groups = model$meta$n_groups to use them all, which is what a
like-for-like check against the observed frame wants. The draws attribute
records which observed team each simulated one came from.
composition takes one row – or several – of block-level covariate
values on their natural scale, so a caller can ask for "a course whose
participants look like this". Each row is a course: n_groups teams are
drawn inside each of them, and they share one drawn session effect,
because groups in one course played in one session. The result carries a
.composition column naming the row each simulated team belongs to.
Conditioning changes two things. The covariates' fixed contribution is
added to the linear predictor, and the session effect is drawn from the
residual block SD – what is left after the covariates – rather than
from the total. With composition = NULL neither happens: the covariates
sit at their fitted mean and the session effect is drawn from the total
block SD, which is the marginal spread of an unseen course and exactly
what a model fitted without covariates produces from the same seed.
A composition value outside the range the fitted courses covered is warned about rather than refused. It is extrapolation from a between-course slope estimated on as many points as there were courses, and it does not travel.
composition cannot be combined with new_teams = FALSE: reusing the
fitted teams reuses the courses they actually played in, so asking for a
hypothetical composition at the same time asks for two contradictory
things.
Each row is labelled by the block key when the caller passes one, so a
slice of sdt_course_composition() keeps its file name and the reader can
see which real course a hypothetical one was copied from. That label is a
course identifier, and sdt_export_simulation() will refuse to write it
into a pseudonymised bundle – rename the key column to something neutral
before simulating if the result is going to be exported.
Each outcome was fitted separately, so a simulated team's tendencies are drawn independently across outcomes. Real teams that image more also operate more; that correlation is absent here. Card-to-card dependence within a casualty (an eFAST leading to a CT) is likewise absent.
Every simulated group is also dealt every card, while an observed group can
have missing outcome values – a casualty its team never triaged. Those
groups differ in which cards contribute to their rate, and that is spread
the simulation has no way to produce. On the validation corpus red is
missing in 105 of 3410 cells and its dispersion ratio reads 1.20 over all
110 groups but 0.97 over the 64 groups with a complete card set.
model$meta$sds$n_missing counts those cells, so a fidelity comparison can
restrict itself to the groups that have none.
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) sdt_simulate_groups(fit, n_groups = 5, seed = 1) }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) sdt_simulate_groups(fit, n_groups = 5, seed = 1) }
Draws teams that were never in the data and, for each simulated team and
each card, a called sighting category given the card's true one. It
is the generative counterpart of sdt_fit_triage_error(): where that fits
the rows of the confusion matrix, this replays them.
sdt_simulate_triage( model, n_groups = 20L, cards = NULL, seed = NULL, new_teams = TRUE, nsim = 1L, team_effects = TRUE )sdt_simulate_triage( model, n_groups = 20L, cards = NULL, seed = NULL, new_teams = TRUE, nsim = 1L, team_effects = TRUE )
model |
An |
n_groups |
Number of simulated teams per replicate. |
cards |
Cards to deal to each team. |
seed |
Optional integer seed, applied with |
new_teams |
If |
nsim |
Number of replicates. Replicates are stacked and carry a
|
team_effects |
If |
Each simulated team gets its own random effect per cell, drawn from
N(0, sd_group), plus a session effect from N(0, sd_block). For a card
of true category k the call is then a two-step draw: correct with
probability plogis(card effect + team effect + session effect) from the
hit cell, and otherwise one of the two wrong categories from the split
cell. That is the confusion-matrix row, redrawn for a new team.
An S3 object of class sdt_simulated_triage, a
tibble with .replicate, sim_group, card,
triage, called_triage, and the hit, over and under logicals.
Carries the model, the call, a draws attribute holding the random
effects used, and a gaps attribute counting the cells left NA.
NA
A cell that was never fitted is never guessed. If a category's hit cell
is missing, every card of that category returns NA; if only its split
cell is missing, the correct calls stand and the errors return NA. A card
with no coefficient in a cell – a card the split part never saw, because
no team miscalled it – returns NA for the same reason. Every one of
these is counted in the result's gaps attribute and warned about once.
The two parts are drawn independently, so a simulated team's accuracy and
its error direction are uncorrelated. That is the one parameter the
decomposition gives up, and joint = TRUE in sdt_fit_triage_error()
measures it. Card-to-card dependence within a team – a team that has just
over-triaged three greens in a row – is absent as well: cards are drawn
independently given the team.
sdt_fit_triage_error(), sdt_error_fidelity().
set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { fit <- sdt_fit_triage_error(d, verbose = FALSE) sdt_simulate_triage(fit, n_groups = 5, seed = 1) }set.seed(1) cards <- sprintf("c%02d", seq_len(12)) truth <- rep(c("red", "yellow", "green"), each = 4) d <- expand.grid(card = cards, group_id = sprintf("g%02d", seq_len(20)), stringsAsFactors = FALSE) d$session_id <- ifelse(d$group_id < "g11", "s1", "s2") d$triage <- truth[match(d$card, cards)] b <- stats::rnorm(20, sd = 1) p <- stats::plogis(1 + b[match(d$group_id, unique(d$group_id))]) hit <- stats::runif(nrow(d)) < p wrong <- vapply(seq_len(nrow(d)), function(i) { sample(setdiff(sdt_triage_levels(), d$triage[[i]]), 1) }, character(1)) d$called_triage <- ifelse(hit, d$triage, wrong) if (requireNamespace("lme4", quietly = TRUE)) { fit <- sdt_fit_triage_error(d, verbose = FALSE) sdt_simulate_triage(fit, n_groups = 5, seed = 1) }
Reports how faithfully a synthetic cohort reproduces its seed, per variable, so a replica can be judged before it is released. Categorical columns are compared by total variation distance between their proportion tables; numeric columns by standardised difference in means and by the Kolmogorov-Smirnov statistic.
sdt_synthesis_utility(original, synthetic)sdt_synthesis_utility(original, synthetic)
original |
The real seed cohort. |
synthetic |
A replica, e.g. from |
A tibble::tibble() with one row per shared column and statistic:
variable, type, statistic, value, and flag, which is TRUE
where the divergence exceeds a conventional 0.1 and the column deserves a
look. Returns a 0-row tibble of the same shape when nothing is shared.
seed_cohort <- sdt_synthetic_cohort(n = 80, seed = 1) replica <- sdt_synthetic_cohort(n = 80, seed = 2) sdt_synthesis_utility(seed_cohort, replica)seed_cohort <- sdt_synthetic_cohort(n = 80, seed = 1) replica <- sdt_synthetic_cohort(n = 80, seed = 2) sdt_synthesis_utility(seed_cohort, replica)
Fits sequential CART models to a real casualty cohort with
synthpop::syn() and draws a synthetic replica from them. Use this when the
real data cannot be shared but its multivariate structure should survive
into something that can be.
sdt_synthesise(data, n = NULL, method = "cart", seed = NULL, verbose = FALSE)sdt_synthesise(data, n = NULL, method = "cart", seed = NULL, verbose = FALSE)
data |
A real cohort, or any data frame carrying the cohort columns. |
n |
Number of synthetic rows to draw. Defaults to |
method |
synthpop method, passed to |
seed |
Optional integer seed, applied with |
verbose |
If |
Unlike sdt_synthetic_cohort(), which generates from marginals alone, this
reproduces the joint structure of the seed data, including relationships
nobody thought to specify. That is the point, and also the risk: a synthetic
record that lands too close to a real one is a disclosure. Score the replica
with sdt_synthesis_utility() before releasing it.
A cohort object of n rows. Identifier and ordering columns are
regenerated rather than synthesised, because carrying them over would leak
the seed data's row identities.
sdt_synthesis_utility() to score the replica against its seed.
seed_cohort <- sdt_synthetic_cohort(n = 60, seed = 1) if (requireNamespace("synthpop", quietly = TRUE)) { replica <- sdt_synthesise(seed_cohort, n = 30, seed = 2) replica }seed_cohort <- sdt_synthetic_cohort(n = 60, seed = 1) if (requireNamespace("synthpop", quietly = TRUE)) { replica <- sdt_synthesise(seed_cohort, n = 30, seed = 2) replica }
Produces a reproducible synthetic cohort, one row per casualty, with the
fields expected by sdt_scenario(). Marginals default to the published
reference distributions returned by sdt_reference(), and can be
overridden.
sdt_synthetic_cohort( n = 20L, triage_prob = NULL, seed = NULL, backend = c("base", "simstudy") )sdt_synthetic_cohort( n = 20L, triage_prob = NULL, seed = NULL, backend = c("base", "simstudy") )
n |
Number of casualties to generate (positive whole number). |
triage_prob |
Named numeric vector of sighting-category probabilities. Defaults to the published triage mix. Need not sum to 1; it is normalised. |
seed |
Optional integer seed. Applied with |
backend |
Either |
A cohort object (a tibble::tibble() subclass) with columns id,
age, sex, life_points, ventilated, injury_region, triage,
therapy, arrival_order. The "simstudy" backend additionally carries
iss, an injury-severity proxy.
sdt_synthesise() to synthesise from a real seed cohort instead.
sdt_synthetic_cohort(n = 10, seed = 42) # Override the mix to stress the red pathway. sdt_synthetic_cohort( n = 10, triage_prob = c(red = 0.8, yellow = 0.2), seed = 1 )sdt_synthetic_cohort(n = 10, seed = 42) # Override the mix to stress the red pathway. sdt_synthetic_cohort( n = 10, triage_prob = c(red = 0.8, yellow = 0.2), seed = 1 )
A small FT-Transformer-shaped model for the workbench: one self-attention
block over per-feature embeddings, mean-pooled to a softmax. Registered as
the "transformer" engine in sdt_engines() and graded "closed", because
it decides in a representation it learned rather than in the columns it was
given, so a per-column attribution describes something it no longer reasons
in.
sdt_transformer(d_model = 16L, num_heads = 2L, epochs = 30L)sdt_transformer(d_model = 16L, num_heads = 2L, epochs = 30L)
d_model |
Embedding width per feature. |
num_heads |
Attention heads. |
epochs |
Training epochs. |
On a corpus whose effective sample size is the number of distinct cards, this is expected to match rather than beat a logistic regression, and to say so. It becomes the right tool when the training set is expanded with simulated cohorts.
A parsnip model specification.
## Not run: sdt_transformer(d_model = 16, num_heads = 2) ## End(Not run)## Not run: sdt_transformer(d_model = 16, num_heads = 2) ## End(Not run)
Hit rate per sighting category and overall, with the confusion matrix, and with the two directions of error kept apart. This is the function that only becomes answerable once a gold standard exists: without one, the same arithmetic measures inter-team agreement.
sdt_triage_accuracy(x, truth = "triage", estimate = "called_triage", by = NULL)sdt_triage_accuracy(x, truth = "triage", estimate = "called_triage", by = NULL)
x |
A data frame carrying both labels. |
truth |
Column holding the true category. With a cohort that has been
through |
estimate |
Column holding the called category. |
by |
Optional character vector of columns to split by, for example
|
An sdt_triage_accuracy object: a tibble::tibble() subclass with
the by columns, then category (each triage level plus "overall"),
n, n_hit, hit_rate, n_over, over_rate, n_under, under_rate
and kappa. The confusion attribute holds the matrix (truth in rows,
estimate in columns), or a named list of them when by is given; meta
records the columns used and how many rows were unusable.
A triage error is not symmetric. On the severity order
red > yellow > green, calling a casualty more urgent than they are is an
over-triage: it wastes a scarce resource. Calling them less urgent is
an under-triage: it can cost a life. A single accuracy number hides
which of the two a team is doing, so both rates are reported alongside the
hit rate, per category and overall.
Cohen's kappa is reported on the overall row: it is a property of the whole table, and repeating it on every category row would state it four times without adding anything.
sdt_read_gold_standard(), sdt_apply_gold_standard().
calls <- data.frame( triage = c("red", "red", "yellow", "green", "green"), called_triage = c("red", "yellow", "yellow", "yellow", "green") ) acc <- sdt_triage_accuracy(calls) acc attr(acc, "confusion")calls <- data.frame( triage = c("red", "red", "yellow", "green", "green"), called_triage = c("red", "yellow", "yellow", "yellow", "green") ) acc <- sdt_triage_accuracy(calls) acc attr(acc, "confusion")
Character vectors giving the allowed levels for the core categorical fields used throughout the package. Exposed so downstream code and the Shiny app can validate against a single source of truth.
sdt_triage_levels() sdt_therapy_levels() sdt_diagnostics() sdt_board_areas() sdt_life_range() sdt_injury_regions() sdt_sex_levels()sdt_triage_levels() sdt_therapy_levels() sdt_diagnostics() sdt_board_areas() sdt_life_range() sdt_injury_regions() sdt_sex_levels()
Each is a character vector:
sdt_triage_levels()Sighting categories "red", "yellow",
"green" (SK I / II / III).
sdt_therapy_levels()Therapy concepts "ETC", "DCS", "TASC",
and "none" for the card's "no operation" option.
sdt_diagnostics()Diagnostic modalities printed on the card:
"efast", "xray", "ct".
sdt_board_areas()The twelve board states a casualty can occupy:
the nine of the published board, "morgue" and "external" for the two
terminal states real exercises produce but the published figure does not
report (died on the board, and transferred out of the hospital), and
"not_seen" for a casualty the game ended before. The engine has no
branch to "morgue" or "external"; they exist so that observed data
can be represented without being folded into a state it is not.
sdt_life_range()The inclusive range of life points, 0:6. Zero
is the death marker printed on the version 2.0 card: a casualty who ran
out of life points during play. Generators draw from 1:6 only, since
death is a state reached in play rather than a card attribute.
sdt_injury_regions()Leading injury region recorded on the card.
sdt_sex_levels()Recorded sex: "f", "m", "d" (diverse).
A character vector of allowed levels.
sdt_reference() for the published marginals over these levels.
sdt_triage_levels() sdt_therapy_levels() sdt_diagnostics() sdt_board_areas() sdt_life_range() sdt_injury_regions() sdt_sex_levels()sdt_triage_levels() sdt_therapy_levels() sdt_diagnostics() sdt_board_areas() sdt_life_range() sdt_injury_regions() sdt_sex_levels()
Fits each available engine to the same data with the same preprocessing and the same resamples, and returns a leaderboard. This is the whole bench: glass-box and black-box models scored side by side, so the price of interpretability is a number rather than an opinion.
sdt_workflowset( data, target = c("triage", "therapy", "called_triage", "called_therapy"), engines = NULL, predictors = NULL, tune = TRUE, grid = 5L, folds = 5L, prop = 0.75, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )sdt_workflowset( data, target = c("triage", "therapy", "called_triage", "called_therapy"), engines = NULL, predictors = NULL, tune = TRUE, grid = 5L, folds = 5L, prop = 0.75, parallel = FALSE, workers = NULL, seed = NULL, verbose = TRUE )
data |
A cohort or data frame of casualties. |
target |
Outcome to predict. |
engines |
Which engines to screen. Defaults to all of them. |
predictors |
Character vector of predictor columns. Defaults to the card fields, excluding identifiers and every outcome column, so a model for the participant's call cannot simply read the true category. |
tune |
If |
grid |
Number of candidate hyperparameter settings. |
folds |
Number of cross-validation folds, stratified on the target. |
prop |
Proportion of the data used for training in the initial split. |
parallel |
If |
workers |
Number of parallel workers, or |
seed |
Optional integer seed, applied with |
verbose |
If |
Engines whose package is not installed are skipped, and named in the
skipped attribute rather than silently dropped. So is the transformer
when keras3 is installed but has no working backend. The transformer
is fitted after every other engine, so Python and its backend are not
loaded into the session before their parallel workers are started.
A tibble::tibble() with one row per engine and metric:
engine, class_box, .metric, .estimator, .estimate, ranked with
the best roc_auc first. Carries a skipped attribute listing engines
that could not be fitted here, and a models attribute holding the fitted
sdt_model objects.
co <- sdt_synthetic_cohort(n = 150, seed = 4) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { sdt_workflowset(co, target = "triage", engines = "tree", tune = FALSE, seed = 1, verbose = FALSE) }co <- sdt_synthetic_cohort(n = 150, seed = 4) needed <- c("parsnip", "recipes", "workflows", "rsample", "tune", "yardstick", "rpart") if (all(vapply(needed, requireNamespace, logical(1), quietly = TRUE))) { sdt_workflowset(co, target = "triage", engines = "tree", tune = FALSE, seed = 1, verbose = FALSE) }
Reports where the casualties stood when the clock stopped, which is the
quantity the published reference distribution describes. A casualty's end
state is the last board area it had entered by the horizon; one that never
cleared triage counts as "not_seen".
## S3 method for class 'game_run' summary(object, reference = FALSE, version = c("2.0", "1.0"), ...)## S3 method for class 'game_run' summary(object, reference = FALSE, version = c("2.0", "1.0"), ...)
object |
A |
reference |
If |
version |
Which evaluated version of the exercise to compare against,
|
... |
These dots are for future extensions and must be empty. |
Two granularities are available. By default the tibble has one row per board
area. With reference = TRUE it is folded onto the eight locations the
source reports, because those are the only ones a published proportion
exists for: the two emergency-department areas become one, and the green
treatment area joins the ward. Both pairings are recorded in
sdt_reference("defaults")$end_state_map rather than being applied here, so
they can be inspected and argued with.
A tibble::tibble() with columns area, label, n and
proportion; when reference = TRUE, also reference and difference.
Every area is present even at a zero count, so the shape does not depend
on the run.
sdt_reference() for the published values and their provenance.
co <- sdt_synthetic_cohort(n = 40, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) summary(run) summary(run, reference = TRUE) summary(run, reference = TRUE, version = "1.0")co <- sdt_synthetic_cohort(n = 40, seed = 1) run <- sdt_simulate(sdt_scenario(co), replications = 2, seed = 1, verbose = FALSE) summary(run) summary(run, reference = TRUE) summary(run, reference = TRUE, version = "1.0")
Checks a cohort against the domain invariants: controlled vocabularies for
sex, injury_region, triage and therapy; life_points inside
1:6; unique non-missing id; and a positive whole arrival_order. All
violations across all columns are collected and reported together.
validate_cohort(x, call = rlang::caller_env())validate_cohort(x, call = rlang::caller_env())
x |
A |
call |
The calling environment, used to attribute the error. |
x, invisibly, on success; otherwise an error of class
sandtablR_error_cohort.
co <- sdt_cohort(sdt_synthetic_cohort(n = 5, seed = 1)) validate_cohort(co)co <- sdt_cohort(sdt_synthetic_cohort(n = 5, seed = 1)) validate_cohort(co)
Checks that a patient_card satisfies the domain invariants: the controlled
vocabularies for sex, injury_region, triage and therapy, and
life_points inside 1:6.
validate_patient_card(x, call = rlang::caller_env())validate_patient_card(x, call = rlang::caller_env())
x |
A |
call |
The calling environment, used to attribute the error. |
The validated x, invisibly on success; otherwise an error of class
sandtablR_error_card.
validate_cohort(), which applies the same rules to a whole cohort
in one vectorised pass.
card <- sdt_patient_card( "P01", 34, "m", 4L, TRUE, "thorax", "red", "DCS", 1L ) validate_patient_card(card)card <- sdt_patient_card( "P01", 34, "m", 4L, TRUE, "thorax", "red", "DCS", 1L ) validate_patient_card(card)