--- title: "GRASS Report Card: a one-call workflow for binary rater reliability" author: - Austin Semmel - Rachel Gidaro date: "2026-07-04" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{GRASS Report Card: a one-call workflow for binary rater reliability} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Abstract Rater-reliability coefficients drift with prevalence, rater count, and sample size, so a fixed-cutoff label like "substantial agreement" can land on the same panel and mean different things. This package replaces fixed cutoffs with a context-conditioned **Report Card**, computed in one call from the rating matrix alone. The workflow returns four fields: the sample summary, the primary coefficient with its surface percentile, the cross-coefficient asymmetry diagnostic, and (when the panel disagrees with itself) a per-rater latent-class fit. ### What's new in 0.6.0 - **Krippendorff's α removed from the Report Card and δ̂ (0.6.0):** in the binary fully-crossed designs this package targets, α coincides with Fleiss' κ asymptotically (median absolute difference 0.00024 across the 10,140-cell calibration grid), so its row added no information and its small-sample deviation could tip borderline δ̂ readings across a threshold on noise alone. δ̂ is now the percentile spread over PABAK, mean AC1, and Fleiss' κ; the bundled thresholds hold for the 3-coefficient spread (sub-pp shift, within MC-SE). `obs_krippendorff_alpha()` is exported for anyone needing the value, and `position_on_surface(metric = "krippendorff_a")` still positions it on its closed-form reference. - **Legacy pre-0.2.0 API removed (0.6.0):** `grass_format_report()`, `grass_methods()`, `grass_report_by()`, and the `grass_result` methods. See `NEWS.md`. Full release history, including the 0.5.x sysdata corrections, is in `NEWS.md`. ## 1. The headline call The entire workflow is three lines: load the package, pass an `N x k` binary rating matrix, print the result. ```r library(grassr) set.seed(2026) # Tiny demo: 5 raters, 200 subjects, modest agreement. truth <- rbinom(200, 1, 0.30) Y <- sapply(1:5, function(j) { ifelse(truth == 1, rbinom(200, 1, 0.85), rbinom(200, 1, 0.15)) }) grass_report(ratings = Y) #> GRASS Report Card #> #> sample = 5 raters, N = 200, pi_hat = 0.34 #> PABAK = 0.47 -> 27th percentile (decisive) <- primary #> AC1 = 0.52 -> 26th percentile #> Fleiss kappa = 0.41 -> 31st percentile #> ICC = 0.53 -> 50th percentile [distribution-sensitive] #> delta = 4.7 pp (aligned) #> thresholds = (9.25, 10.75) [calibrated at this (k, N)] #> #> Notes: #> - Fitted-ICC F_key picked via glmer: mu_hat=-1.039, tau2_hat=3.669 -> F_key tau2=4.0000, mu=-0.847. #> - Fitted-ICC reference (GLMM-gap corrected) at F_key=LN_mu=-0.847_tau2=4.0000, k=5, N=200 (family=logit_normal, M1=0.374). #> #> See `summary(...)` for full panel and CI details. #> See `plot(...)` for a surface-position visualization. ``` Each block in the printed Report Card means something specific: - **`sample`** describes the study: rater count `k`, subject count `N`, and the observed positive rate `pi_hat = mean(Y)`. - **The primary coefficient line** (e.g. `PABAK = 0.41 -> 95th pct (decisive)`) shows the observed value, where it lands on the reference surface *for this study's `(k, N, pi_hat)`*, and the confidence qualifier (decisive / moderate / weak) reflecting the local envelope width. The percentile is the categorical score; no four-way labeled band sits between the percentile and the reader. - **`delta`** is the cross-coefficient percentile spread in pp across the agreement family (PABAK, AC1, Fleiss kappa; ICC excluded by construction). The caution and divergent cuts are size-α calibrated per `(k, N)` (`delta_thresholds_lookup`); at the modal applied design `(k = 5, N = 200)` the pair is `(9.25, 10.75)` pp. Below the caution cut the panel is *aligned* and the primary coefficient is safe to cite; above the divergent cut the panel- aggregate is suppressed in favor of the pairwise PABAK matrix and per-rater pooled-reference Se/Sp. ## 2. A symmetric panel (the easy case) Five raters with the same accuracy (Se = Sp = 0.85) on a moderate-prevalence binary task. The panel should land in the *aligned* tier: every coefficient agrees about where the panel sits, so a single number is enough. ```r set.seed(29) N <- 200; k <- 5 truth <- rbinom(N, 1, 0.30) Y_sym <- sapply(1:k, function(j) { ifelse(truth == 1, rbinom(N, 1, 0.85), rbinom(N, 1, 0.15)) }) card_sym <- grass_report(ratings = Y_sym) card_sym #> GRASS Report Card #> #> sample = 5 raters, N = 200, pi_hat = 0.35 #> PABAK = 0.41 -> 95th percentile (decisive) <- primary #> AC1 = 0.46 -> 94th percentile #> Fleiss kappa = 0.36 -> 95th percentile #> ICC = 0.46 -> 50th percentile [distribution-sensitive] #> delta = 1.2 pp (aligned) #> thresholds = (9.25, 10.75) [calibrated at this (k, N)] #> #> Notes: #> - Fitted-ICC F_key picked via glmer: mu_hat=-0.877, tau2_hat=2.801 -> F_key tau2=4.0000, mu=-0.847. #> - Fitted-ICC reference (GLMM-gap corrected) at F_key=LN_mu=-0.847_tau2=4.0000, k=5, N=200 (family=logit_normal, M1=0.374). #> #> See `summary(...)` for full panel and CI details. #> See `plot(...)` for a surface-position visualization. ``` Note three things: 1. The `flag` is `aligned` (`delta = 1.2 pp`, well below the 9.25-pp caution threshold). One coefficient is safe to cite. 2. Only the primary coefficient prints (PABAK, picked automatically by the `(k, pi_hat)` rule from Table 2 of the paper). The full panel of four coefficients is still computed and stored on the object; it is just not the headline. 3. The qualifier (`decisive`) is calibrated, not inherited from a fixed-band convention: it is the bootstrap probability that the modal surface region survives perturbation of `(q_hat, pi_hat)`, mapped to decisive / moderate / weak at 0.90 / 0.60. A four-band label is computed and stored on the object for programmatic use; the printed headline carries the percentile and the qualifier only. ## 3. A divergent panel Now the paper's divergent worked example (the `op_strong` profile): five raters with *alternating* per-rater bias direction at balanced prevalence. Three raters lean toward over-calling (Se = 0.95, Sp = 0.75); two lean toward under-calling (Se = 0.75, Sp = 0.95). The asymmetry is severe per rater but cancels in the panel-aggregate marginals, so each agreement coefficient returns roughly the same observed value, yet their inversions onto the (symmetric-DGP) reference surfaces split across percentiles. This is the configuration where the headline `grass_report` flag flips from a single coherent reading to a per-rater diagnostic. ```r set.seed(6) N <- 1000; k <- 5 # Alternating per-rater Se/Sp asymmetry: R1, R3, R5 favor sensitivity; # R2, R4 favor specificity. Subject-level positive probabilities are # logit-normal around balanced prevalence (the paper's divergent DGP). Se <- c(0.95, 0.75, 0.95, 0.75, 0.95) Sp <- c(0.75, 0.95, 0.75, 0.95, 0.75) p_i <- plogis(rnorm(N, qlogis(0.50), sqrt(0.25))) truth <- rbinom(N, 1L, p_i) Y_div <- matrix(0L, N, k) for (j in seq_len(k)) { Y_div[, j] <- rbinom(N, 1L, ifelse(truth == 1L, Se[j], 1 - Sp[j])) } card_div <- grass_report(ratings = Y_div, bootstrap_B = 200) card_div #> GRASS Report Card #> #> sample = 5 raters, N = 1000, pi_hat = 0.50 #> PABAK = 0.49 -> 40th percentile <- primary #> AC1 = 0.49 -> 58th percentile #> Fleiss kappa = 0.49 -> 41st percentile #> ICC = 0.62 -> 50th percentile [distribution-sensitive] #> panel-agg. = suppressed (divergent) #> delta = 18 pp (divergent) #> thresholds = (8.50, 9.75) [calibrated at nearest grid cell (snapped)] #> #> pairwise PABAK / surface percentile (lower / upper): #> R1 R2 R3 R4 R5 #> R1 -- 25% 79% 43% 98% #> R2 0.47 -- 97% 90% 17% #> R3 0.51 0.41 -- 98% 96% #> R4 0.48 0.52 0.42 -- 62% #> R5 0.55 0.46 0.54 0.50 -- #> #> per-rater vs panel-majority of OTHER raters (pooled-reference): #> R1 Se_tilde = 0.94 Sp_tilde = 0.77 (n_pos = 434, n_neg = 471, excl = 95) #> R2 Se_tilde = 0.71 Sp_tilde = 0.95 (n_pos = 466, n_neg = 439, excl = 95) #> R3 Se_tilde = 0.95 Sp_tilde = 0.71 (n_pos = 433, n_neg = 485, excl = 82) #> R4 Se_tilde = 0.74 Sp_tilde = 0.95 (n_pos = 468, n_neg = 441, excl = 91) #> R5 Se_tilde = 0.96 Sp_tilde = 0.78 (n_pos = 428, n_neg = 476, excl = 96) #> #> per-rater (latent-class fit; alongside pairwise): #> R1 Se = 0.95 (0.23, 0.97) Sp = 0.78 (0.06, 0.82) #> R2 Se = 0.73 (0.05, 0.77) Sp = 0.95 (0.29, 0.97) #> R3 Se = 0.96 (0.29, 0.97) Sp = 0.72 (0.05, 0.76) #> R4 Se = 0.75 (0.05, 0.79) Sp = 0.95 (0.26, 0.97) #> R5 Se = 0.96 (0.22, 0.98) Sp = 0.78 (0.04, 0.82) #> #> Notes: #> - (k = 5, N = 1000) has no fully-calibrated threshold pair on the grid (or is off-grid); using nearest fully-calibrated cell (k = 5, N = 500). #> - Fitted-ICC F_key picked via glmer: mu_hat=0.005, tau2_hat=5.278 -> F_key tau2=4.0000, mu=0.000. #> - Fitted-ICC reference (GLMM-gap corrected) at F_key=LN_mu=+0.000_tau2=4.0000, k=5, N=1000 (family=logit_normal, M1=0.500). #> #> See `summary(...)` for full panel and CI details. #> See `plot(...)` for a surface-position visualization. ``` The flag is `divergent`: the cross-coefficient percentile spread clears the divergent threshold printed on the card (9.75 pp, snapped from the nearest calibrated cell) even though the three agreement coefficients (PABAK, AC1, Fleiss kappa) all sit near the same observed value. Three things change in the printed output: 1. The full panel of percentiles prints, not just the primary, so the reader can see that AC1 separates from the kappa-family coefficients. 2. The `band` is `suppressed`. Picking a single number from a panel whose coefficients disagree on the surface position would hide the asymmetry. 3. The per-rater table appears. A latent-class fit (Dawid-Skene EM at k >= 3, Hui-Walter bounds at k = 2) recovers each rater's sensitivity and specificity with bootstrap confidence intervals. The recovered estimates pin the panel's structure: R1, R3, R5 land near (Se, Sp) = (0.95, 0.75), R2, R4 near (0.75, 0.95). The bootstrap CIs are wide because the latent-class likelihood becomes ridge-flat near balanced prevalence and the bootstrap resamples both the correct mode and its label-switched counterpart; the point estimates pin the local mode, but without external orientation the practitioner cannot assert from the rating matrix alone which raters are over- versus under-callers. ### Pairwise reliability under the divergent flag When the panel is divergent the framework's recommended primary deliverable is `pairwise_agreement()`: a `k x k` matrix of pairwise PABAK values, each placed on the `k = 2` reference surface at the pair's observed marginal, plus per-rater behavior against the panel-majority of the *other* `k - 1` raters as a proxy reference. The pairwise matrix exposes the panel's structure directly, and the pooled-reference per-rater table avoids the latent-class identifiability problem (label-switching is moot because the "reference" is the observable panel majority, not a latent class). ```r pw <- pairwise_agreement(Y_div) pw #> GRASS Pairwise Reliability #> #> sample = 5 raters, N = 1000, pi_hat = 0.50, tau2_hat = 0.122, axis = inter #> #> Pairwise PABAK (lower triangle = PABAK_ij; upper triangle = surface percentile): #> #> R1 R2 R3 R4 R5 #> R1 -- 25% 79% 43% 98% #> R2 0.47 -- 97% 90% 17% #> R3 0.51 0.41 -- 98% 96% #> R4 0.48 0.52 0.42 -- 62% #> R5 0.55 0.46 0.54 0.50 -- #> #> Per-rater behavior against pooled panel-majority: #> #> rater Se_tilde Sp_tilde n_pos n_neg n_excl #> R1 0.94 0.77 434 471 95 #> R2 0.71 0.95 466 439 95 #> R3 0.95 0.71 433 485 82 #> R4 0.74 0.95 468 441 91 #> R5 0.96 0.78 428 476 96 #> (Se_tilde, Sp_tilde are calls vs panel-majority of OTHER raters, #> not against external truth. n_excl: subjects with tied majority, #> excluded from the per-rater pool.) ``` Read the per-rater table: with R1, R3, R5 favoring sensitivity and R2, R4 favoring specificity, the pooled-reference estimates pin each rater's bias direction without ambiguity. R1's `Se_tilde` is high (calls true positives at panel-majority rate) while `Sp_tilde` is lower (calls panel-majority negatives less reliably): the over-caller signature. R2 shows the mirror: low `Se_tilde`, high `Sp_tilde`. The latent-class fit's wide bootstrap CIs in the paper's divergent example had to live with the label-switched mode; the pooled-reference per-rater table sidesteps that ambiguity. The pairwise PABAK matrix lower triangle shows the actual pairwise agreements; the upper triangle shows surface percentiles at each pair's observed marginal. Note that the same PABAK value can land at different surface percentiles across pairs because each pair sees a different `pi_hat_ij`; the divergent flag is exposing exactly this prevalence-by-pair structure that a panel-aggregate summary would have averaged away. ## 4. Layered access Every field beneath the printed Report Card is available on demand. ```r summary(card_sym) #> GRASS Report Card -- summary #> #> sample : k = 5 raters, N = 200, pi_hat = 0.354, axis = inter #> tau2_hat : 0.083 #> #> primary coefficient #> name : pabak #> observed : 0.414 #> percentile : 95.42 pp #> band : Strong #> qualifier : decisive #> #> delta (cross-coefficient asymmetry) #> delta_hat : 1.20 pp #> flag : aligned #> thresholds : caution = 9.25, divergent = 10.75 #> #> panel (full table) #> pabak observed = 0.414 pct = 95.42 pp band = Strong qualifier = decisive #> mean_ac1 observed = 0.460 pct = 94.22 pp band = Strong qualifier = decisive #> fleiss_kappa observed = 0.359 pct = 94.67 pp band = Strong qualifier = decisive #> icc observed = 0.464 pct = 50.00 pp band = Strong qualifier = decisive #> #> notes #> - Fitted-ICC F_key picked via glmer: mu_hat=-0.877, tau2_hat=2.801 -> F_key tau2=4.0000, mu=-0.847. #> - Fitted-ICC reference (GLMM-gap corrected) at F_key=LN_mu=-0.847_tau2=4.0000, k=5, N=200 (family=logit_normal, M1=0.374). #> #> grass version : 0.6.2 #> timestamp : 2026-07-04 10:43:05 ``` `summary()` is the *audit view*: full panel of coefficients with percentiles and bands, all caveats from the surface lookup, the GRASS version, the timestamp. Use it when you want to see what the headline hides. ```r head(as.data.frame(card_sym), 5) #> coefficient observed_value surface_percentile band qualifier #> 1 pabak 0.4140000 95.41694 Strong decisive #> 2 mean_ac1 0.4601604 94.21535 Strong decisive #> 3 fleiss_kappa 0.3593780 94.66860 Strong decisive #> 4 icc 0.4641657 50.00000 Strong decisive #> band_probability_modal q_hat se_q_hat clamped reference_used #> 1 1 0.8217138 0.013797700 FALSE closed-form #> 2 1 0.8217650 0.013796793 FALSE closed-form #> 3 1 0.8217138 0.013797700 FALSE closed-form #> 4 1 0.8075234 0.005569156 FALSE fitted-icc #> in_delta_hat is_primary #> 1 TRUE TRUE #> 2 TRUE FALSE #> 3 TRUE FALSE #> 4 FALSE FALSE ``` `as.data.frame()` is the *tidy view*: one row per coefficient with `observed_value`, `surface_percentile`, `band`, `qualifier`, and `is_primary`. Use it when you want to bind many studies' Report Cards into one table for a meta-analysis or to feed the panel into another analysis pipeline. ```r plot(card_sym) # default: surface with observation pinned #> Warning: The following aesthetics were dropped during statistical transformation: fill. #> ℹ This can happen when ggplot fails to infer the correct grouping structure in #> the data. #> ℹ Did you forget to specify a `group` aesthetic or to convert a numerical #> variable into a factor? #> Warning: Removed 120 rows containing missing values or values outside the scale range #> (`geom_raster()`). ```
plot of chunk plot-surface
plot of chunk plot-thermometer