The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
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.
Eyewitness researchers frequently need to decide whether one identification procedure is better than another: whether simultaneous lineups beat sequential ones, whether a longer retention interval hurts, whether lineup size is important.
Since about 2012 the dominant way of answering such questions has
been to compare ROC curves, and usually to reduce each
curve to a single number — the partial area under it
(pAUC) — and test whether the two numbers differ. This vignette
shows how to do that with compare_pauc(). It also explains
what the curves and the areas mean, and why the approach is
contested.
Consider a lineup procedure and a set of witnesses, some viewing lineups containing the culprit (target-present) and some viewing lineups containing an innocent suspect instead (target-absent). Two things can happen that we care about:
Any procedure can be made to produce more correct identifications simply by encouraging witnesses to choose more freely — but that also produces more false identifications. The two rates move together, which is exactly why comparing procedures on identification rate alone is uninformative.
The ROC curve addresses this by plotting one against the other as we vary how demanding we are about accepting an identification. In eyewitness work the lever is confidence: count only identifications made with the highest confidence, and both rates are low; progressively admit lower-confidence identifications and both rates rise. Each confidence threshold gives one point, and joining them gives the curve.
set.seed(2026)
strong <- as.data.frame(simulate_lineup_data(
n_tp = 400, n_ta = 400, d_prime = 2.0, c_criterion = 0.9,
lineup_size = 6, conf_levels = 5,
foil_bias = c(0.4, 0.2, 0.0, -0.4, -1.0), seed = 1
))
roc_points <- make_rocdata(strong, lineup_size = 6)$roc_data
knitr::kable(
roc_points[, c("confidence", "n_correct_ids", "n_false_ids",
"correct_id_rate", "false_id_rate")],
digits = 4, row.names = FALSE,
caption = "ROC points for one simulated condition (400 target-present and 400 target-absent lineups, d' = 2.0). Each row is a confidence threshold: rows lower down admit less confident identifications."
)| confidence | n_correct_ids | n_false_ids | correct_id_rate | false_id_rate |
|---|---|---|---|---|
| 6 | 0 | 0 | 0.0000 | 0.0000 |
| 5 | 81 | 1 | 0.2025 | 0.0025 |
| 4 | 135 | 3 | 0.3375 | 0.0075 |
| 3 | 202 | 12 | 0.5050 | 0.0300 |
| 2 | 251 | 30 | 0.6275 | 0.0750 |
| 1 | 286 | 45 | 0.7150 | 0.1125 |
Reading down the table: taking only identifications at the highest confidence gives a correct identification rate of about 0.2 against a false identification rate of about 0.002. Admitting every identification regardless of confidence raises the correct rate to about 0.72 but the false rate to about 0.11.
A curve that bows further towards the top-left corner — more correct identifications for the same number of false ones — indicates a better procedure.
The ROC curve exists to separate two things that identification rates confound.
Discriminability is the witness’s ability to tell the culprit apart from innocent lineup members. It is a property of memory and of the viewing conditions, and it is what the shape and position of the ROC curve reflect. A procedure that genuinely improves memory moves the whole curve outward.
Criterion is how willing a witness is to identify anyone at all. A cautious witness identifies rarely; a willing one identifies often. Changing the criterion moves you along a curve without changing which curve you are on.
The distinction is important because a procedure can change identification rates without improving anything. If sequential lineups simply make witnesses more cautious, they will produce fewer false identifications and fewer correct ones, and a comparison of raw rates will make them look better or worse depending on which rate the reader happens to weight. The ROC curve answers the question that survives a criterion shift: at the same false identification rate, which procedure gets more correct identifications?
This is the argument Wixted and Mickes (2012) made in urging the field to abandon the diagnosticity ratio in favour of ROC analysis, and it is why ROC analysis became standard.
A textbook ROC curve runs from (0,0) to (1,1). Lineup ROC curves do not, and this is the source of most of the difficulty.
roc_df <- roc_points[order(roc_points$false_id_rate), ]
ggplot(roc_df, aes(x = false_id_rate, y = correct_id_rate)) +
geom_abline(slope = 1, intercept = 0, linetype = "dotted", colour = "grey60") +
geom_line(linewidth = 1, colour = "steelblue") +
geom_point(size = 2.5, colour = "steelblue") +
annotate("segment",
x = max(roc_df$false_id_rate), xend = 1,
y = max(roc_df$correct_id_rate), yend = 1,
linetype = "dashed", colour = "firebrick") +
annotate("text", x = 0.55, y = 0.72, colour = "firebrick", hjust = 0,
label = "no data here:\nwitnesses never choose\nthis freely") +
coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
labs(
x = "False identification rate",
y = "Correct identification rate",
title = "A lineup ROC curve occupies only a corner of the space",
subtitle = "Simulated data, 400 target-present and 400 target-absent lineups, d' = 2.0"
) +
theme_minimal(base_size = 12)The curve stops at a false identification rate of about 0.11. It stops there because that is what happens when every identification is counted, however hesitant. There is no way to push further to the right: doing so would require witnesses to identify someone more often than they are willing to, and the experiment provides no lever for that.
Two consequences follow, and both are important:
The standard response is to give up on the full area and compute the area only over the region that was actually observed: the partial AUC.
cutoff <- max(roc_df$false_id_rate)
shade <- rbind(
data.frame(false_id_rate = 0, correct_id_rate = 0),
roc_df[, c("false_id_rate", "correct_id_rate")],
data.frame(false_id_rate = cutoff, correct_id_rate = 0)
)
ggplot(roc_df, aes(x = false_id_rate, y = correct_id_rate)) +
geom_polygon(data = shade, fill = "steelblue", alpha = 0.25) +
geom_line(linewidth = 1, colour = "steelblue") +
geom_point(size = 2.5, colour = "steelblue") +
labs(
x = "False identification rate",
y = "Correct identification rate",
title = "Partial AUC is the shaded area, over the observed region only",
subtitle = "Simulated data, d' = 2.0. Note the x-axis stops well short of 1"
) +
theme_minimal(base_size = 12)Partial AUC is defended on four grounds, which are taken separately here because they are not equally strong:
Point 4 carries a condition that is easy to violate, and the next section is about what happens when it is.
It would be misleading to present pAUC as settled method. It is not, and the objections are substantive.
Partial AUC requires a region, and with two curves of different lengths there is no choice of region that is obviously right.
This is not hypothetical. compare_pauc() defaults to the
largest region both curves attain, which is the second option above, and
it says so when the two differ:
weak <- as.data.frame(simulate_lineup_data(
n_tp = 400, n_ta = 400, d_prime = 1.4, c_criterion = 0.9,
lineup_size = 6, conf_levels = 5,
foil_bias = c(0.4, 0.2, 0.0, -0.4, -1.0), seed = 2
))
reach_strong <- max(make_rocdata(strong, lineup_size = 6)$roc_data$false_id_rate)
reach_weak <- max(make_rocdata(weak, lineup_size = 6)$roc_data$false_id_rate)
knitr::kable(
data.frame(
Condition = c("Strong (d' = 2.0)", "Weak (d' = 1.4)"),
`Maximum false-ID rate reached` = round(c(reach_strong, reach_weak), 4),
check.names = FALSE
),
row.names = FALSE,
caption = "The two simulated conditions stop at different points, so there is no single region both fully cover."
)| Condition | Maximum false-ID rate reached |
|---|---|
| Strong (d’ = 2.0) | 0.1125 |
| Weak (d’ = 1.4) | 0.1150 |
Smith, Lampinen, Wells, Smalarz and Mackovichova (2019) go further
and argue that pAUC does not measure what researchers want it to
measure. Their claim is that pAUC fails to track the diagnostic
utility of a lineup — the thing that is actually important for
deciding which procedure a police force should adopt — and that neither
choice of region repairs this. They propose deviation from perfect
performance (DPP) instead, which is implemented in this package and
covered in vignette("calibration_decision_analysis").
A further criticism concerns what enters the analysis at all. The ROC curve above is built from suspect identifications only. A witness who identifies a foil has made an error that the analysis largely sets aside, and a witness who rejects the lineup contributes nothing to either axis.
Yet foil identifications are informative: they say something about
how well the lineup was constructed, and a procedure that converts
suspect identifications into foil identifications has genuinely
protected an innocent suspect. Lineup rejections are informative too.
The full ROC approach, which uses every response, is one answer to this;
see vignette("fullroc_analysis").
The point is not that pAUC should be abandoned, but that a single number computed over a contested region from a subset of the responses is carrying more weight in this literature than it can comfortably bear. Reporting it alongside DPP, and alongside the full response distribution, is a more defensible practice than reporting it alone.
| Reference | Contribution |
|---|---|
| Wixted & Mickes (2012) | The case for ROC analysis, and against the diagnosticity ratio, in eyewitness research |
| Gronlund, Wixted & Mickes (2014) | Accessible account of applying ROC analysis to identification procedures |
| Smith et al. (2019) | The case against pAUC: it does not track diagnostic utility. Proposes DPP |
| Mickes et al. (2024) | pyWitness, the reference implementation this package’s ROC code was checked against |
compare_pauc() computes
\[Z = \frac{\widehat{pAUC}_1 - \widehat{pAUC}_2}{\widehat{SE}(\widehat{pAUC}_1 - \widehat{pAUC}_2)}\]
and refers \(Z\) to a standard normal distribution.
It is important to make a point of clarification here about what kind of test this is, because it is easy to assume it is something it is not. It is a Wald test: an estimate divided by an estimate of its own standard error. It is not a comparison of two standard normal deviates, and it is not a test on transformed proportions. The normal reference distribution comes from the asymptotic normality of the estimator, not from any assumption that the underlying data are normal.
The standard error in the denominator is obtained by bootstrap: the witnesses in each condition are resampled with replacement, the whole ROC and its partial area are recomputed for each resample, and the standard deviation of the resulting differences is taken as \(\widehat{SE}\).
Because no usable closed-form expression exists. The partial area is a non-linear functional of a step function estimated from two correlated sets of counts, computed over a region that is itself determined by the data. Deriving its sampling variance analytically is not practical; resampling estimates it directly and makes no distributional assumption in doing so.
The Wald test assumes the difference in pAUC is approximately normally distributed. That can be checked, rather than assumed:
# Stored with the vignette; set R4LINEUPS_PRECOMPUTE=true to recompute.
comparison <- compare_pauc(
strong, weak,
lineup_size = 6,
label1 = "Strong (d' = 2.0)",
label2 = "Weak (d' = 1.4)",
n_bootstrap = 600,
seed = 123
)boot_diff <- comparison$bootstrap_results$diff_boot
knitr::kable(
data.frame(
Quantity = c("Mean", "SD", "Skewness", "Excess kurtosis",
"Shapiro-Wilk p"),
Value = c(
round(mean(boot_diff), 5),
round(sd(boot_diff), 5),
round(mean((boot_diff - mean(boot_diff))^3) / sd(boot_diff)^3, 3),
round(mean((boot_diff - mean(boot_diff))^4) / sd(boot_diff)^4 - 3, 3),
signif(shapiro.test(boot_diff)$p.value, 3)
)
),
row.names = FALSE,
caption = "Shape of the bootstrap distribution of the pAUC difference, simulated data (600 bootstrap resamples)."
)| Quantity | Value |
|---|---|
| Mean | 0.01547 |
| SD | 0.00674 |
| Skewness | -0.04200 |
| Excess kurtosis | 0.54700 |
| Shapiro-Wilk p | 0.08370 |
ggplot(data.frame(diff = boot_diff), aes(x = diff)) +
geom_histogram(aes(y = after_stat(density)), bins = 40,
fill = "steelblue", colour = "white", alpha = 0.8) +
stat_function(fun = dnorm,
args = list(mean = mean(boot_diff), sd = sd(boot_diff)),
colour = "firebrick", linewidth = 1) +
geom_vline(xintercept = 0, linetype = "dotted") +
labs(
x = "pAUC difference (strong - weak)",
y = "Density",
title = "Bootstrap distribution of the pAUC difference",
subtitle = "Simulated data. Red curve = normal with the same mean and SD; dotted line = no difference"
) +
theme_minimal(base_size = 12)Typically the distribution is close to normal but not exactly so — mild skew is common, because pAUC is bounded and the region is data-determined. With several hundred resamples a formal normality test will often reject even when the departure is too small to be important. The practical question is not whether normality holds exactly but whether it holds well enough that the interval is about right, and that is best answered by comparing the two intervals directly:
knitr::kable(
data.frame(
Method = c("Wald (normal approximation)", "Bootstrap percentile"),
Lower = round(c(comparison$ci_diff[["lower"]],
unname(quantile(boot_diff, 0.025))), 5),
Upper = round(c(comparison$ci_diff[["upper"]],
unname(quantile(boot_diff, 0.975))), 5)
),
row.names = FALSE,
caption = "95% confidence intervals for the pAUC difference by two methods, simulated data."
)| Method | Lower | Upper |
|---|---|---|
| Wald (normal approximation) | 0.00431 | 0.03072 |
| Bootstrap percentile | 0.00230 | 0.02886 |
When the two agree closely, as here, the normal approximation is doing no harm. When they diverge, prefer the percentile interval: it inherits whatever shape the bootstrap distribution actually has, rather than imposing symmetry on it.
print(comparison)
#>
#> === pAUC Comparison Analysis ===
#>
#> Conditions:
#> Strong (d' = 2.0): pAUC = 0.0617 (SE = 0.0051)
#> Weak (d' = 1.4): pAUC = 0.0442 (SE = 0.0041)
#>
#> Difference: 0.0175
#> 95% CI: [0.0043, 0.0307]
#>
#> Statistical Test:
#> Z = 2.6
#> p-value = 0.009329
#>
#> Interpretation: Strong (d' = 2.0) has higher discriminability than Weak (d' = 1.4) (**)
#>
#> Max false ID rate cutoff: 0.1125
#> Bootstrap samples: 600
#>
#> Note: *** p<0.001, ** p<0.01, * p<0.05, ns = not significantsummary(comparison)
#>
#> === pAUC Comparison Summary ===
#>
#> Condition 1: Strong (d' = 2.0)
#> Sample size: 800
#> Target-present: 400
#> Target-absent: 400
#> pAUC: 0.0617
#> SE(pAUC): 0.0051
#> 95% CI: [0.0518, 0.0717]
#>
#> Condition 2: Weak (d' = 1.4)
#> Sample size: 800
#> Target-present: 400
#> Target-absent: 400
#> pAUC: 0.0442
#> SE(pAUC): 0.0041
#> 95% CI: [0.0361, 0.0523]
#>
#> Difference (Strong (d' = 2.0) - Weak (d' = 1.4)):
#> Estimate: 0.0175
#> SE(diff): 0.0067
#> 95% CI: [0.0043, 0.0307]
#> Z-score: 2.6
#> p-value: 0.009329
#>
#> SE-standardized difference (depends on sample size; not Cohen d): 3.781
#>
#> Analysis Details:
#> Max false ID rate cutoff: 0.1125
#> Bootstrap samples: 600
#> Confidence level:95%The default region is the largest both curves reach. A policy-relevant cutoff can be imposed instead, provided it is one both curves actually attain:
comparison_10 <- compare_pauc(
strong, weak,
lineup_size = 6,
max_false_id_rate = 0.10,
label1 = "Strong", label2 = "Weak",
n_bootstrap = 300,
seed = 456
)
knitr::kable(
data.frame(
Region = c(sprintf("Default (to %.3f)", comparison$max_false_id_rate),
"Policy cutoff (to 0.100)"),
`pAUC difference` = round(c(comparison$pauc_diff,
comparison_10$pauc_diff), 5),
`p-value` = signif(c(comparison$p_value, comparison_10$p_value), 3),
check.names = FALSE
),
row.names = FALSE,
caption = "The same two conditions compared over two different regions, simulated data."
)| Region | pAUC difference | p-value |
|---|---|---|
| Default (to 0.113) | 0.01752 | 0.00933 |
| Policy cutoff (to 0.100) | 0.01527 | 0.00212 |
The estimates differ because they are areas over different intervals. This is not an inconsistency to be resolved but the region problem in miniature: the region is part of the estimand, and reporting a pAUC without stating the region it covers is incomplete.
The bootstrap standard error is itself an estimate, and too few resamples make it unstable. For exploratory work a few hundred is adequate; anything intended for publication should use 2000 or more. This vignette uses small numbers so that it builds quickly.
Comparing three or more conditions pairwise inflates the family-wise error rate in the usual way:
medium <- as.data.frame(simulate_lineup_data(
n_tp = 400, n_ta = 400, d_prime = 1.7, c_criterion = 0.9,
lineup_size = 6, conf_levels = 5,
foil_bias = c(0.4, 0.2, 0.0, -0.4, -1.0), seed = 3
))
pairs <- list(
`Strong vs Medium` = list(strong, medium),
`Strong vs Weak` = list(strong, weak),
`Medium vs Weak` = list(medium, weak)
)# Stored with the vignette; set R4LINEUPS_PRECOMPUTE=true to recompute.
results <- do.call(rbind, lapply(names(pairs), function(nm) {
cmp <- compare_pauc(pairs[[nm]][[1]], pairs[[nm]][[2]],
lineup_size = 6, n_bootstrap = 200, seed = 41)
data.frame(Comparison = nm,
`pAUC difference` = round(cmp$pauc_diff, 5),
Z = round(cmp$z_score, 3),
p = signif(cmp$p_value, 4),
check.names = FALSE)
}))results$`p (Bonferroni)` <- signif(p.adjust(results$p, method = "bonferroni"), 4)
knitr::kable(
results, row.names = FALSE,
caption = "Pairwise pAUC comparisons with Bonferroni adjustment, simulated data (400 target-present and 400 target-absent lineups per condition)."
)| Comparison | pAUC difference | Z | p | p (Bonferroni) |
|---|---|---|---|---|
| Strong vs Medium | 0.00335 | 0.598 | 0.549600 | 1.00000 |
| Strong vs Weak | 0.01752 | 2.718 | 0.006571 | 0.01971 |
| Medium vs Weak | 0.01192 | 2.190 | 0.028510 | 0.08553 |
Bonferroni is the most conservative option and the easiest to defend;
Holm is uniformly more powerful and equally easy to justify
(p.adjust(method = "holm")). Better still, where the design
allows it, is to nominate one primary comparison in advance and treat
the rest as exploratory.
Planning a study that will compare pAUC needs a power analysis, which
simulate_power_analysis() provides — it uses
compare_pauc() internally as its test. See
vignette("simulation_power_analysis").
The ROC points are the cumulative correct- and false-identification rates at each confidence threshold \(c\):
\[\text{CID}(c) = \frac{\#\{\text{suspect IDs in target-present lineups with confidence} \ge c\}}{N_{\text{target-present}}}\]
\[\text{FID}(c) = \frac{\#\{\text{suspect IDs in target-absent lineups with confidence} \ge c\}}{N_{\text{target-absent}}}\]
Where target-absent lineups contain no designated innocent suspect, foil identifications divided by lineup size are used to estimate the false identification rate instead.
The partial area over \([0, f_{\max}]\) is computed by the trapezoidal rule over the observed points:
\[\widehat{pAUC} = \sum_{i=2}^{k} \bigl(\text{FID}_i - \text{FID}_{i-1}\bigr) \cdot \frac{\text{CID}_i + \text{CID}_{i-1}}{2}\]
If \(f_{\max}\) falls between two observed points, the curve is linearly interpolated to that point. If \(f_{\max}\) lies beyond the last observed point, no extrapolation is performed: the sum simply stops at the data. Supplying a cutoff beyond a curve’s reach therefore produces a warning and an area truncated at the observed reach, which is smaller than the area up to the requested cutoff would be. Read the warning; it means the two areas being compared may not cover the same false-ID range.
For each of \(B\) bootstrap resamples, witnesses within each condition are resampled with replacement, preserving the target-present and target-absent sample sizes; the ROC and its partial area are recomputed; and the difference is recorded. Then
\[\widehat{SE}(\widehat{pAUC}_1 - \widehat{pAUC}_2) = \operatorname{sd}\bigl(\{\widehat{pAUC}_1^{(b)} - \widehat{pAUC}_2^{(b)}\}_{b=1}^{B}\bigr)\]
The two conditions are resampled independently, which is correct for a between-subjects design and gives \(\widehat{SE}^2 \approx \widehat{SE}_1^2 + \widehat{SE}_2^2\). It would be wrong for a within-subjects design in which the same witnesses contribute to both conditions, because the two areas would then be correlated and the independent resampling would overstate the standard error. This package provides no within-subjects version.
summary() reports a standardised effect size formed by
dividing the pAUC difference by a pooled bootstrap standard deviation.
It is a useful relative summary, but it should not be read against
Cohen’s conventions for \(d\): those
were calibrated for standardised mean differences between raw
observations, not for a difference between two areas scaled by a
resampling standard deviation, and the two are not on comparable
footings.
Gronlund, S. D., Wixted, J. T., & Mickes, L. (2014). Evaluating eyewitness identification procedures using receiver operating characteristic analysis. Current Directions in Psychological Science, 23(1), 3-10.
Mickes, L., Seale-Carlisle, T. M., Chen, X., & Boogert, S. (2024). pyWitness 1.0: A python eyewitness identification analysis toolkit. Behavior Research Methods, 56, 1533-1550.
Smith, A. M., Lampinen, J. M., Wells, G. L., Smalarz, L., & Mackovichova, S. (2019). Deviation from perfect performance measures the diagnostic utility of eyewitness lineups but partial area under the ROC curve does not. Journal of Applied Research in Memory and Cognition, 8(1), 50-59.
Wixted, J. T., & Mickes, L. (2012). The field of eyewitness memory should abandon probative value and embrace receiver operating characteristic analysis. Perspectives on Psychological Science, 7(3), 275-278.
?compare_pauc for complete parameter documentationvignette("fullroc_analysis") for ROC analysis using
every responsevignette("calibration_decision_analysis") for DPP, the
proposed alternativevignette("simulation_power_analysis") for planning a
study of this kindThese binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.