
rtprep and bmm: the correspondence, function by function
Source:vignettes/articles/rtprep-and-bmm.Rmd
rtprep-and-bmm.Rmdbmm fits measurement models, among them the EZ-diffusion
model and the full diffusion model, and it ships four helpers for the
preprocessing that comes before a fit. rtprep is the
package for that preprocessing, and it reimplements those four rather
than depending on bmm, so that a package about
preprocessing choices carries no model-fitting dependency and every rule
returns the same shape. Reimplementation has a known cost: nothing
catches a silent divergence from the published algorithm except a test
that compares the two. rtprep’s test suite runs those
comparisons on every check, and this page runs the same comparisons
live, so what it shows is demonstrated on the versions named at the
bottom rather than asserted.
The correspondence
bmm |
rtprep |
What differs |
|---|---|---|
flag_contaminant_rts(rt, distribution, contaminant_bound, init_contaminant, max_contaminant, maxit = 100) |
rt_screen(rt, rule_mixture(distribution, bound, init, max_prop, maxit = 500)) |
returns P(contaminant); .prob is P(valid). Diagnostics
as an attribute; screen_fits() as a table. NA
on a failed fit; every trial kept. |
ezdm_summary_stats(rt, response, method = "mixture", ...) |
rt_summary(rt, response, method = "simple", ...) |
same columns, same names; the default method differs, and
maxit
|
adjust_ezdm_accuracy(n_upper, n_trials, contaminant_prop, guess_rate) |
adjust_accuracy(n_upper, n_trials, contaminant_prop, guess_rate) |
identical, draw for draw, and vectorised over rows |
validate_fast_guesses(contam_flag, rt_data, response, ..., guess_prob) |
check_guessing(keep, rt, response, ..., chance) |
takes the keep flag rather than the contaminant flag; returns a one-row data frame rather than a list |
The comparisons below pass maxit = 500 on both sides,
because that is the one default that differs and a comparison at two
different iteration limits compares nothing.
Flagging
bmm::flag_contaminant_rts() returns the posterior
probability that each trial is a contaminant. rt_screen()
with rule_mixture() returns the complement, the probability
that the trial came from the decision process, because the same column
has to hold the 0 and 1 of a deterministic rule and “1 means keep” is
the convention that makes filter(.keep) read correctly. The
mapping is one minus the other:
p3_hard <- rt_example |>
filter(id == "p3", condition == "hard")
theirs <- bmm::flag_contaminant_rts(
p3_hard$rt, distribution = "lognormal", maxit = 500
)
ours <- rt_screen(p3_hard$rt, rule_mixture("lognormal", maxit = 500))
max(abs((1 - ours$.prob) - as.numeric(theirs)))
#> [1] 3.095631e-05
all(ours$.keep == (as.numeric(theirs) < 0.5))
#> [1] TRUEThe two agree to the optimiser’s tolerance rather than exactly,
because rtprep uses the closed-form weighted maximiser for
the lognormal and inverse-Gaussian cores where bmm
optimises numerically; the test suite allows the decisions to differ
only for a trial whose posterior sits within 0.01 of the cut. The fit
diagnostics are the same quantities in two shapes: bmm
attaches a one-row data frame with a list column of parameters,
rtprep returns one row per group with the parameters as
par_ columns, so that a comparison across participants is a
table without unnesting:
attr(theirs, "diagnostics") |>
select(contaminant_prop, converged, iterations, loglik)
#> contaminant_prop converged iterations loglik
#> 1 0.0272247 TRUE 12 10.73694
screen_fits(p3_hard$rt, rule_mixture("lognormal", maxit = 500)) |>
select(contaminant_prop, converged, iterations, loglik, par_mu, par_sigma)
#> contaminant_prop converged iterations loglik par_mu par_sigma
#> 1 0.02722565 TRUE 12 10.73694 -0.5553563 0.3382753When the EM does not converge bmm returns
NA probabilities. rtprep keeps every trial in
that group and records converged = FALSE, because an
NA would propagate into .keep and a screen
that cannot be evaluated has no grounds to remove data.
Summary statistics
bmm::ezdm_summary_stats() and rt_summary()
return the same columns under the same names, for every method and both
versions, so a summary table built with one drops into a workflow
written for the other:
bind_rows(lapply(c("simple", "robust", "mixture"), function(method) {
ours <- rt_summary(
p3_hard$rt, p3_hard$response,
method = method, distribution = "lognormal", maxit = 500
)
theirs <- bmm::ezdm_summary_stats(
p3_hard$rt, p3_hard$response,
method = method, distribution = "lognormal", maxit = 500
)
tibble(
method = method,
same_names = identical(names(ours), names(theirs)),
max_abs_diff = max(abs(unlist(ours) - unlist(theirs)), na.rm = TRUE)
)
}))
#> # A tibble: 3 × 3
#> method same_names max_abs_diff
#> <chr> <lgl> <dbl>
#> 1 simple TRUE 0
#> 2 robust TRUE 0
#> 3 mixture TRUE 0.00000224The simple and robust moments agree exactly; the mixture moments
agree to the optimiser’s tolerance. Two defaults differ.
bmm defaults to method = "mixture" and
rtprep to "simple", because a package about
preprocessing choices should not make one of them silently. And
rtprep defaults to maxit = 500 where
bmm keeps 100; the mixture
article shows what the cap does to a fit that has not converged. For
version = "4par" with mixture moments both packages resolve
the contaminant bounds once on the pooled response times before
splitting by boundary, so the two halves are fitted against the same
uniform component.
Accuracy adjustment
Both functions draw the number of contaminants and the number that happened to be correct as binomials, and they make the two draws in the same order, so from the same seed they return the same counts:
set.seed(2026097)
adjust_accuracy(n_upper = 84, n_trials = 100, contaminant_prop = 0.10)
#> n_upper_adj n_trials_adj
#> 1 80 91
set.seed(2026097)
bmm::adjust_ezdm_accuracy(n_upper = 84, n_trials = 100, contaminant_prop = 0.10)
#> n_upper_adj n_trials_adj
#> 1 80 91rtprep’s version is vectorised over its rows, so the
columns of a summary table go in directly; bmm’s takes one
row at a time.
The guessing check
check_guessing() is a port of
bmm::validate_fast_guesses() with two interface changes. It
takes the keep flag, so the .keep column of a
screen drops in without negation, and it returns a one-row data frame,
because in practice the result goes into a results table. The argument
chance is bmm’s guess_prob:
screened <- rt_screen(
rt_example$rt, rule_cutoff(0.35, 3),
.by = list(rt_example$id, rt_example$condition)
)
ours <- check_guessing(screened$.keep, rt_example$rt, rt_example$response)
theirs <- bmm::validate_fast_guesses(
contam_flag = !screened$.keep, rt_data = rt_example$rt,
response = rt_example$response
)
c(ours = ours$bf_01, bmm = theirs$bf_01)
#> ours bmm
#> 3.523941 3.523941
c(ours = ours$n_tested, bmm = theirs$n_tested)
#> ours bmm
#> 19 19The differences, listed
Beyond the two defaults, the packages part in six places, each deliberate:
-
.probis P(valid), the complement of whatflag_contaminant_rts()returns. -
A rule that cannot be evaluated removes nothing.
Fewer than two trials in a group, zero spread, a sample below the
smallest tabled size for the recursive criteria, or an unconverged
mixture:
rtprepkeeps the trials and says so in the fits table, wherebmmreturnsNA. -
Numeric
responsemust be coded 0/1.bmmcoerces withas.logical(), which reads a 1 = error / 2 = correct column as perfect accuracy;rtpreprefuses it instead. - Warnings come once per call, naming how many groups failed, rather than once per group.
-
Bounds are inclusive. A trial is flagged only when
it falls strictly outside
rule_cutoff()’s bounds;trimruses strict comparisons, so the two can disagree on a trial sitting exactly on a bound. -
ez_ddm()usess = 1, asbmmdoes, where Wagenmakers et al. use 0.1; drift and bound scale withsand non-decision time does not.
Handing a screened data set to bmm
The point of matching the column names is that a bmm fit
takes rtprep’s output without translation. Both models need
Stan, so the calls are shown and not run. The EZ-diffusion model fits a
summary table, which rt_summary() and
adjust_accuracy() produce per cell:
library(bmm)
ezdm_data <- trial_data |>
reframe(
rt_summary(rt, response, method = "mixture", distribution = "lognormal"),
.by = c(subject, condition)
) |>
mutate(adjust_accuracy(n_upper, n_trials, contaminant_prop))
fit_ez <- bmm(
formula = bmf(drift ~ condition, bound ~ 1, ndt ~ 1),
data = ezdm_data,
model = ezdm(
mean_rt = "mean_rt", var_rt = "var_rt",
n_upper = "n_upper_adj", n_trials = "n_trials_adj"
)
)The full diffusion model fits trial-level data, which a screen filters in one verb:
clean_data <- trial_data |>
filter(rt_keep(rt, rule_recursive("modified"), .by = list(subject, condition)))
fit_ddm <- bmm(
formula = bmf(drift ~ condition, bound ~ 1, ndt ~ 1),
data = clean_data,
model = ddm(resp1 = "rt", resp2 = "response")
)The alternative to filtering is to keep every trial and hand the mixture’s posterior to the aggregation as a weight; the aggregation article compares the two.
trimr
trimr implements the absolute cutoff, the SD criterion,
and van Selst and Jolicoeur’s criteria, and returns trimmed data or
per-cell means rather than per-trial decisions. Its
sdTrim() trims the slow side and leaves the fast side to a
separate minRT argument, so the comparison is on the upper
tail; rtprep flags both tails from one rule. The two keep
the same trials:
trimr_data <- rt_example |>
transmute(participant = id, condition, rt, accuracy = response)
kept_by_trimr <- trimr::sdTrim(
trimr_data,
minRT = 0, sd = 2.5, perCondition = TRUE, perParticipant = TRUE,
omitErrors = FALSE, returnType = "raw"
)
kept_by_rtprep <- rt_example |>
filter(rt_keep(rt, rule_sd(2.5), .by = list(id, condition)))
#> sd(2.5, mean, sd): dropped 26 of 800 trials (3.2%)
c(trimr = nrow(kept_by_trimr), rtprep = nrow(kept_by_rtprep))
#> trimr rtprep
#> 774 774
setequal(kept_by_trimr$rt, kept_by_rtprep$rt)
#> [1] TRUEThe recursive criteria compare on the per-cell means that
trimr returns:
trimr_means <- trimr::modifiedRecursive(
trimr_data,
minRT = 0, omitErrors = FALSE, returnType = "mean", digits = 10
)
rtprep_means <- rt_example |>
filter(rt_keep(rt, rule_recursive("modified"), .by = list(id, condition))) |>
summarise(mean_rt = mean(rt), .by = c(id, condition)) |>
tidyr::pivot_wider(names_from = condition, values_from = mean_rt)
#> recursive(modified): dropped 33 of 800 trials (4.1%)
all.equal(
as.numeric(as.matrix(trimr_means[, c("hard", "easy")])),
as.numeric(as.matrix(rtprep_means[, c("hard", "easy")]))
)
#> [1] TRUETwo documented divergences remain. trimr compares
strictly and rtprep inclusively, so a trial sitting exactly
on a bound is removed by one and kept by the other. And
trimr’s modified recursive procedure removes an extreme by
value, which would take every tied copy at once, where
rtprep removes it by position; with continuous response
times the two agree, as they do above. trimr’s hybrid
statistic, the mean of the two condition means, is recoverable from
rtprep by summarising the two variants separately, as
?rule_recursive shows, and not from any single keep
vector.
Versions this page was built against
c(
rtprep = as.character(packageVersion("rtprep")),
bmm = if (has_bmm) as.character(packageVersion("bmm")) else "not installed",
trimr = if (has_trimr) as.character(packageVersion("trimr")) else "not installed"
)
#> rtprep bmm trimr
#> "0.1.0" "1.3.2" "1.1.1"A release of bmm or trimr that changed an
algorithm would show here, and in rtprep’s check workflow,
which installs both and runs the same comparisons with tighter
tolerances than a page can show.