A rule is a parameter object; the engine applies it. Adding one takes a
single call to new_rule() with fun =, the function that decides which
trials to keep. The function travels on the rule object, so there is no S3
method to write and nothing to register, and rt_screen(), rt_keep(),
screen_fits() and screen_compare() all pick the rule up unchanged.
Usage
new_rule(
subclass,
label,
...,
fun = NULL,
description = NULL,
reason = "contaminant",
needs_response = NULL,
per_trial = character(),
grouped = FALSE
)
apply_rule(rule, rt, response = NULL)
apply_rule_grouped(rule, rt, response = NULL, idx_by_group)Arguments
- subclass
A string naming the rule family. The object's class becomes
c("rtprep_rule_<subclass>", "rtprep_rule"), with"rtprep_rule_fun"between them when the rule carries its own function.- label
A string identifying the rule and its settings, used as the
.rulecolumn and byprint(). Conventionally"family(setting, setting)".- ...
Named parameters stored on the rule, passed to
funby name and available to anapply_rule()method asrule$name.- fun
The screening function, or
NULLto write anapply_rule()method instead. See "What the screening function receives" and "What the screening function must return".- description
A sentence saying what the rule does, shown by
print().- reason
A string naming what a dropped trial was dropped for, used for the
.reasoncolumn whenfunreturns a bare vector.rtprep's own rules use"too_fast","too_slow"and"contaminant".- needs_response, per_trial, grouped
See "Declaring what a rule needs".
- rule
A rule object, as built by
new_rule().- rt
Numeric vector of response times for one group, in seconds, with missing values already removed.
- response
Numeric 0/1 vector of the same length, or
NULL.- idx_by_group
A list of integer vectors, one per group, giving each group's positions in
rt.
Value
new_rule() returns an object of class
c("rtprep_rule_<subclass>", "rtprep_rule"), with "rtprep_rule_fun"
inserted before the last when fun is given. apply_rule() returns the
three-element list described under "If you are writing a package".
Details
rule_custom() does the same thing in one call, without a constructor, for
a rule used once. A package shipping a family of rules can instead write a
method for the apply_rule() generic; see "If you are writing a package".
What the screening function receives
The function declares what it needs by name, and the engine passes exactly that and nothing else. It can supply:
rtthe response times of one group, in seconds, with missing values already removed and never empty. The function therefore does no validation of its own.
responseaccuracy for the same trials, already coerced to 0/1, or
NULL. Declaring it makes it mandatory; see "Declaring what a rule needs".rulethe rule object itself, for a function that would rather read
rule$namethan take parameters one by one.idx_by_groupfor a
grouped = TRUErule only: a list of integer vectors giving each group's positions inrt.- any parameter stored on the rule
everything passed to
new_rule()through..., by the name it was given there.
A formal the engine cannot supply is an error when the rule is built, rather
than in the middle of a screen — unless it has a default, in which case the
engine leaves it alone and the default applies. Declaring ... means "and
everything else": the function then receives the whole lot.
What the screening function must return
The answer is always in keep terms: TRUE, or a probability near 1, means
the trial stays. That is the direction of the .keep and .prob columns and
the opposite of how an exclusion criterion is usually phrased, so it is worth
checking once on data whose answer you know.
There are three ways to say it, and a rule can start with the first and move to the third without anything else changing.
A logical vector, one value per trial,
TRUEfor a trial to keep. The simplest rule, and what most rules are..probbecomes 0 or 1, and every dropped trial is labelled with the rule'sreason.A numeric vector, one value per trial, in [0, 1]: the probability that the trial came from the decision process. Use this when the rule is a model rather than a cutoff, as
rule_mixture()is.rt_screen()turns it into a decision with its ownpolicyandthreshold, so the function does not decide and must not round.A list of
prob,reasonandfit, the full contract under "If you are writing a package". Use it when different trials are dropped for different reasons, or when the rule estimates something per group — a criterion, a pair of bounds, whether a fit converged — that should become a column ofscreen_fits().reasonis then ignored: the function owns the reason column.
Whichever shape it returns, it returns one value per trial, in the order rt
came in, and no NA. A rule that cannot evaluate a trial or a whole group,
because it has too few trials, a spread of zero, or a fit that did not
converge, returns TRUE, or 1, and removes nothing. Silence is not evidence
of contamination.
Declaring what a rule needs
Three arguments change how the engine calls the rule.
needs_response = TRUE makes response mandatory: rt_screen() then errors
when it is not supplied, instead of the function receiving NULL. Left
unset, it is TRUE for a function that declares a response argument.
per_trial names the elements of ... that hold one value per trial rather
than a parameter. The engine checks their length against rt up front and
subsets them to the group before dispatch, so the function sees only its own
group's values. rule_oracle() uses this to carry ground truth.
grouped = TRUE marks a rule that has to see every group at once, one that
pools information across participants, say. The engine then calls it once,
with all groups, and hands it idx_by_group.
If you are writing a package
A package shipping a family of rules can write a method for the
apply_rule() generic instead of carrying a function on the object. The two
routes are equivalent; a registered apply_rule.rtprep_rule_<subclass>()
method takes precedence, and a fun on the same rule is then ignored.
apply_rule() is called once per group, with rt already stripped of
missing values and guaranteed non-empty, and response already coerced to
0/1 (or NULL). Methods therefore do no validation of their own. A method
returns a list of three elements:
probnumeric,
length(rt), in [0, 1]: the probability that the trial came from the decision process. Note the direction: this is P(valid), not P(contaminant). A deterministic rule returns 0 and 1.reasoncharacter,
length(rt),NAwherever the trial is valid.rtprep's own rules use"too_fast","too_slow"and"contaminant"; a new rule may use any label.fita one-row
data.frameof whatever the rule estimated for that group (bounds, criteria, convergence), orNULLif it estimated nothing. These become the extra columns ofscreen_fits().
A rule that cannot be evaluated on a group returns
prob = rep(1, length(rt)) and removes nothing, for the same reason a
screening function returns TRUE.
A grouped = TRUE rule gets a method for apply_rule_grouped() instead,
called once with every group, and returns fit as a list of one-row data
frames, one per group.
Saving a rule
The function travels with the rule, and a screen carries the rule that made
it, so saving either saves the function and the environment it was written
in. A function defined at the top level costs nothing. One defined inside
another function drags whatever that function was holding into the file with
it, which is how a rule ends up megabytes wide. Define screening functions at
the top level, and pass what they need through ....
See also
rule_custom() for a rule in one call, rules for the families
rtprep ships.
Examples
# a rule that keeps the middle 90% of each group by quantile
rule_middle <- function(p = 0.05) {
new_rule(
"middle",
label = paste0("middle(", p, ")"),
p = p,
description = "Keep the middle 90% of each group by quantile.",
fun = function(rt, p) {
b <- stats::quantile(rt, c(p, 1 - p), names = FALSE)
rt >= b[1] & rt <= b[2]
}
)
}
rule_middle()
#> <rtprep rule> middle(0.05)
#> Keep the middle 90% of each group by quantile.
#> screened by a function of (rt, p)
screen_fits(rt_example$rt, rule_middle(), .by = rt_example$id)
#> .group n_trials n_dropped prop_dropped
#> 1 p1 200 20 0.10
#> 2 p2 200 20 0.10
#> 3 p3 200 20 0.10
#> 4 p4 200 18 0.09
# a graded rule returns a probability and lets rt_screen() decide
shrinking <- rule_custom(
"shrinking",
function(rt) pmin(1, 0.2 / rt),
description = "The slower the trial, the less likely it is a decision."
)
head(rt_screen(rt_example$rt, shrinking, .by = rt_example$id))
#> <rtprep screen> 6 trials, shrinking
#> kept 0 (0.0%), dropped 6 (100.0%)
#> reasons: contaminant 6
#> policy: keep where .prob > 0.5
#> per-group diagnostics: dropped by subsetting; screen_fits() to refit
#>
#> .keep .prob .rule .reason
#> 1 FALSE 0.3389831 shrinking contaminant
#> 2 FALSE 0.4901961 shrinking contaminant
#> 3 FALSE 0.1499250 shrinking contaminant
#> 4 FALSE 0.3552398 shrinking contaminant
#> 5 FALSE 0.3484321 shrinking contaminant
#> 6 FALSE 0.3007519 shrinking contaminant
# the package way: a method, for a family with diagnostics to report
rule_bounded <- function(p = 0.05) {
new_rule("bounded", label = paste0("bounded(", p, ")"), p = p)
}
apply_rule.rtprep_rule_bounded <- function(rule, rt, response = NULL) {
b <- stats::quantile(rt, c(rule$p, 1 - rule$p), names = FALSE)
list(
prob = as.numeric(rt >= b[1] & rt <= b[2]),
reason = ifelse(
rt < b[1], "too_fast", ifelse(rt > b[2], "too_slow", NA)
),
fit = data.frame(lower = b[1], upper = b[2])
)
}
registerS3method(
"apply_rule", "rtprep_rule_bounded", apply_rule.rtprep_rule_bounded
)
screen_fits(rt_example$rt, rule_bounded(), .by = rt_example$id)
#> .group n_trials n_dropped prop_dropped lower upper
#> 1 p1 200 20 0.10 0.3784500 1.015950
#> 2 p2 200 20 0.10 0.3679546 1.043750
#> 3 p3 200 20 0.10 0.3678500 1.141000
#> 4 p4 200 18 0.09 0.3670000 1.269867
