Acknowledgement. Large language models supplied by Anthropic and OpenAI were used in preparing this vignette, to assist with checking code, testing the veracity of the approaches described, and on occasion editing the text. Responsibility for the content rests with the authors.
This vignette estimates signal-detection metrics from old/new recognition data using generalized linear models.
Attribution. The approach is that of Wright, Horry
and Skagerberg (2009), who set out both the traditional per-participant
computation and the multilevel alternative, and released it as the R
package sdtalt. The implementation here —
fit_sdt_glm(), fit_sdt_glmm() and
extract_sdt_metrics() — was written from that paper and
adapted into this package’s conventions; it is not derived from their
code. The multilevel fitting is done by lme4 (Bates,
Mächler, Bolker & Walker, 2015).
Classic signal detection analysis computes, for each participant separately, a hit rate and a false-alarm rate, converts both to z-scores, and takes d′ as their difference:
\[d' = z(\text{hit rate}) - z(\text{false alarm rate})\]
Those per-participant values are then averaged, and a t-test is run on them. This works, and for a simple design it gives sensible answers. But it has four awkward properties:
The GLM formulation avoids all four by modelling the individual response rather than the per-participant summary. d′ follows directly from a probit regression, so nothing is lost by doing it this way.
The connection is exact rather than approximate.
Signal detection theory says the witness’s evidence is normally distributed with mean 0 for new items and mean d′ for old ones, with a criterion at some point \(c\). The probability of saying “old” is the area of that distribution above the criterion:
\[P(\text{said old} \mid \text{new}) = \Phi(-c), \qquad P(\text{said old} \mid \text{old}) = \Phi(d' - c)\]
A probit regression models exactly this form:
\[\Phi^{-1}\bigl(P(\text{said old})\bigr) = \beta_0 + \beta_1 \cdot \text{is old}\]
Matching the two, \(\beta_1 = d'\) and \(\beta_0 = -c\). The coefficient on item status is d′ — not an analogue of it.
The intercept needs one further step. With is_old coded
0/1, \(\beta_0\) is the value when
is_old = 0, so it is the criterion measured from the
new-item distribution. Coding is_old as −0.5/+0.5 instead
centres it, and the intercept becomes the criterion measured from the
midpoint of the two distributions — which is the conventional definition
of \(c\). fit_sdt_glm()
centres by default, which is why the criterion is only reported when
center_isold = TRUE.
With a logit link instead, the same coefficient is a log odds ratio rather than d′. It answers the same question about discriminability but on a different scale, and should not be reported as d′.
set.seed(123)
n_subj <- 40
n_items <- 60
df <- data.frame(
subject_id = rep(1:n_subj, each = n_items),
item_id = rep(1:n_items, times = n_subj)
)
df$is_old <- rbinom(nrow(df), 1, 0.5)
# Average d' = 1.2. Participants and items differ in their tendency to say
# "old" (shifts, which enter on old and new trials alike), and participants
# also differ in their own d' (slope deviations, which enter on old trials only)
subj_shift <- rnorm(n_subj, 0, 0.4)
item_shift <- rnorm(n_items, 0, 0.4)
subj_slope <- rnorm(n_subj, 0, 0.5)
df$said_old <- rbinom(
nrow(df), 1,
stats::pnorm((1.2 + subj_slope[df$subject_id]) * df$is_old +
subj_shift[df$subject_id] + item_shift[df$item_id])
)Both participants and items vary in their response bias, and participants also vary in how well they discriminate old from new. That is realistic: some people are better at recognition than others, and some faces look familiar whether or not they were studied. It is what makes the next section important.
fit_sdt_glmm() fits the same model with random effects,
using lme4::glmer() internally:
glmm_fit <- fit_sdt_glmm(df,
is_old = "is_old",
said_old = "said_old",
subject_id = "subject_id",
item_id = "item_id",
link = "probit",
random_slope = TRUE)
extract_sdt_metrics(glmm_fit)
#> $metric
#> [1] "dprime"
#>
#> $estimate
#> [1] 1.1322
#>
#> $criterion
#> [1] -0.5075608
#>
#> $link
#> [1] "probit"
#>
#> $centered
#> [1] TRUEThis is the main reason to prefer the multilevel formulation, and it is an old argument: Clark (1973) pointed out that treating stimulus items as fixed means any conclusion strictly applies only to the particular items used. In recognition memory the items are faces or words, and they differ in memorability. If your analysis treats them as fixed, you are entitled to conclude something about those sixty faces, not about faces in general.
The traditional workaround was to run the analysis twice — once averaging over items to get a by-participants test, once averaging over participants to get a by-items test — and require both to be significant. Crossed random effects do the job properly in one model.
The consequence is visible in the evidence for d′. Variation in the
tendency to say “old” alone leaves the test of d′ almost unchanged,
because every participant and every item contributes both old and new
trials. Variation in d′ itself is what inflates the evidence in a
single-level GLM, and only a random slope for is_old
captures it. fit_sdt_glmm() offers that slope for
participants (random_slope = TRUE); items always receive a
random intercept only.
structures <- list(
"GLM: no clustering" = fit_sdt_glm(df, "is_old", "said_old",
link = "probit"),
"GLMM: subject only" = fit_sdt_glmm(df, "is_old", "said_old",
subject_id = "subject_id",
link = "probit",
random_slope = FALSE),
"GLMM: subject and item" = fit_sdt_glmm(df, "is_old", "said_old",
subject_id = "subject_id",
item_id = "item_id",
link = "probit",
random_slope = FALSE),
"GLMM: subject slope and item" = glmm_fit
)
comparison <- do.call(rbind, lapply(names(structures), function(nm) {
m <- structures[[nm]]
co <- summary(m)$coefficients
term <- attr(m, "sdt_is_old_term")
if (is.null(term)) term <- ".is_old_c"
data.frame(
`Random effects` = nm,
`d'` = round(co[term, "Estimate"], 3),
`SE` = round(co[term, "Std. Error"], 4),
`z` = round(co[term, "z value"], 1),
check.names = FALSE
)
}))
knitr::kable(
comparison, row.names = FALSE,
caption = "Estimated d', its standard error and z under four random-effect structures. Simulated data, 40 participants by 60 items, average d' = 1.2."
)| Random effects | d’ | SE | z |
|---|---|---|---|
| GLM: no clustering | 0.952 | 0.0553 | 17.2 |
| GLMM: subject only | 1.034 | 0.0589 | 17.5 |
| GLMM: subject and item | 1.072 | 0.0617 | 17.4 |
| GLMM: subject slope and item | 1.132 | 0.0900 | 12.6 |
The raw standard errors are not comparable across rows, because the
estimates themselves differ (see the next subsection); z is the fair
comparison. Adding random intercepts for participants and items leaves z
at about 17, so variation in the tendency to say “old” does not change
the conclusion about d′. Letting d′ itself vary over participants cuts z
to about 13, and the standard error rises by almost two-thirds. Ignoring
that variation treats all 2400 trials as independent, although trials
from the same participant share that participant’s ability. A
GLM that ignores variation in d′ across participants reports a standard
error that is too small, and a p-value that is too generous.
The same holds for variation across items, which
fit_sdt_glmm() does not model.
The d′ estimates also differ across the four rows, and this is not noise. The GLM estimate is marginal (population-averaged): it describes the average response probability across all participants and items. The GLMM estimate is conditional (subject- and item-specific): it describes the effect for a participant and item at the average of the random effects.
For a probit link the two are related by a known attenuation. With only random intercepts, the marginal d′ is approximately the conditional d′ divided by \(\sqrt{1 + \sigma^2_{\text{subject}} + \sigma^2_{\text{item}}}\). With a random slope the variance of the random part differs between new and old items, so the probit-scale value for each kind of item is divided by its own factor \(\sqrt{1 + v(x)}\), where \(v(x) = \sigma^2_{\text{int}} + 2x\sigma_{\text{int,slope}} + x^2\sigma^2_{\text{slope}} + \sigma^2_{\text{item}}\) and \(x\) is \(-0.5\) for new and \(+0.5\) for old items (centred coding). The marginal d′ is the difference between the two scaled values:
fit_re <- structures[["GLMM: subject slope and item"]]
vc <- lme4::VarCorr(fit_re)
v_subj <- vc[[".subject_id"]]
v_item <- vc[[".item_id"]][1, 1]
v_x <- function(x) v_subj[1, 1] + 2 * x * v_subj[1, 2] +
x^2 * v_subj[2, 2] + v_item
b <- lme4::fixef(fit_re)
cond <- b[[".is_old_c"]]
pred <- (b[["(Intercept)"]] + 0.5 * cond) / sqrt(1 + v_x(0.5)) -
(b[["(Intercept)"]] - 0.5 * cond) / sqrt(1 + v_x(-0.5))
marg <- stats::coef(structures[["GLM: no clustering"]])[[".is_old_c"]]
knitr::kable(
data.frame(
Quantity = c("Conditional d' (GLMM with random slope)",
"Random-effect variance, new items",
"Random-effect variance, old items",
"Predicted marginal d'",
"Marginal d' (GLM), observed"),
Value = round(c(cond, v_x(-0.5), v_x(0.5), pred, marg), 3)
),
row.names = FALSE,
caption = "Marginal and conditional d' are different quantities related by a known attenuation. Simulated data."
)| Quantity | Value |
|---|---|
| Conditional d’ (GLMM with random slope) | 1.132 |
| Random-effect variance, new items | 0.289 |
| Random-effect variance, old items | 0.537 |
| Predicted marginal d’ | 0.918 |
| Marginal d’ (GLM), observed | 0.952 |
The predicted marginal value and the observed one agree to within about one standard error of the GLM estimate. So the two models are not disagreeing about the data; they are answering slightly different questions. Neither is wrong, but they should not be compared as though interchangeable, and a paper should say which is being reported. The conditional estimate is often the one meant by “d′” in a cognitive model, since it refers to a typical participant rather than to an average over heterogeneity.
df$delay <- rnorm(nrow(df), 0, 1)
glm_fit_cov <- fit_sdt_glm(df,
is_old = "is_old",
said_old = "said_old",
covariates = "delay",
link = "probit",
interactions = TRUE)
summary(glm_fit_cov)
#>
#> Call:
#> stats::glm(formula = form, family = stats::binomial(link = link),
#> data = df)
#>
#> Coefficients:
#> Estimate Std. Error z value Pr(>|z|)
#> (Intercept) 0.406670 0.027676 14.694 <2e-16 ***
#> .is_old_c 0.951927 0.055353 17.197 <2e-16 ***
#> delay -0.009878 0.027813 -0.355 0.722
#> .is_old_c:delay -0.017666 0.055625 -0.318 0.751
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> (Dispersion parameter for binomial family taken to be 1)
#>
#> Null deviance: 3136.4 on 2399 degrees of freedom
#> Residual deviance: 2827.5 on 2396 degrees of freedom
#> AIC: 2835.5
#>
#> Number of Fisher Scoring iterations: 4With interactions = TRUE, the
is_old × delay term tests whether discriminability itself
depends on the covariate — which is the question usually intended, and
which the two-stage classic approach cannot ask directly.
| Link | Coefficient on is_old |
Intercept (centred coding) |
|---|---|---|
| probit | d′ | −criterion |
| logit | log odds ratio | −criterion on the logit scale |
Standard errors for the GLM come from the usual Fisher information;
for the GLMM they come from lme4’s Laplace approximation to
the marginal likelihood.
center_isold = TRUE (the default) codes item status as
−0.5/+0.5 rather than 0/1. This does not change the coefficient on
is_old, but it changes the intercept from “criterion
relative to the new-item distribution” to “criterion relative to the
midpoint”, which is the conventional \(c\). extract_sdt_metrics()
returns the criterion only under centred coding, since under 0/1 coding
the intercept is not \(c\).
fit_sdt_roc() in R/zroc_sdt.R, or the ordinal
approach discussed below.(1|subject_id) + (1|item_id) (or
(1 + is_old|subject_id) + (1|item_id) with a random slope)
is correct for this. If items were nested within lists, the
specification would differ.random_slope = TRUE.
Allowing d′ itself to vary across participants is often the more
realistic model and requires more data to fit.This treats the response as binary — “old” or “new”. Recognition data usually carry confidence as well, and reducing a confidence rating to a binary response discards it. The natural extension is an ordinal (cumulative link) model with confidence as the ordered outcome, in which the coefficient on item status is still d′ and the thresholds are the criteria; allowing the scale to depend on condition then gives the unequal-variance model. That is not implemented here.
Bates, D., Mächler, M., Bolker, B., & Walker, S. (2015). Fitting linear mixed-effects models using lme4. Journal of Statistical Software, 67(1), 1-48.
Clark, H. H. (1973). The language-as-fixed-effect fallacy: A critique of language statistics in psychological research. Journal of Verbal Learning and Verbal Behavior, 12(4), 335-359.
Wright, D. B., Horry, R., & Skagerberg, E. M. (2009). Functions for traditional and multilevel approaches to signal detection theory. Behavior Research Methods, 41(2), 257-267.
?fit_sdt_glm, ?fit_sdt_glmm,
?extract_sdt_metricsvignette("sdt_summary_comparisons") for comparing SDT
parameters between groupsvignette("model_comparison") for lineup-specific
models