Diagnostics and benchmarking

This vignette shows the diagnostic and benchmarking workflow for crr() fits: the structured diagnostics report, the plotting methods, and the one-call benchmark against the unconstrained baseline. It mirrors, at a CRAN-friendly scale, the simulation comparisons in Statistical Modeling of Combinatorial Response Data.

Setup: simulate and fit

library(combreg)

A <- rbind(
  c(1, 1, 0),
  c(0, 1, 1)
)
con <- crr_constraints(A, b = c(1, 1))
sim <- simulate_crr(n = 80, p = 2, constraints = con, seed = 42)

fit <- crr(sim$Y, sim$X, con,
           n_iter = 800, warmup = 400, chains = 2, seed = 42)
fit
#> <crr_fit> method=mhwg kernel=exponential: n=80, p=2, d=3, chains=2, iterations=800 (warmup 400, thin 1)
#> Mean MH acceptance rate: 0.645
#> Adaptive zeta_block (target accept 0.6): 3
#> Total sampling time: 1.82s

The diagnostics report

crr_diagnostics() assembles everything needed to judge a run in one object: per-coefficient posterior summaries, effective sample sizes (total and per second of sampling), split-Rhat, MH acceptance, in-sample regression fit, and posterior predictive goodness-of-fit checks. In simulation the true coefficients are known, so passing beta additionally reports estimation error (RMSE) and posterior-interval coverage. Potential problems (Rhat above 1.05, ESS below 100, very low acceptance, extreme predictive p-values) are collected as warnings.

report <- crr_diagnostics(fit, beta = sim$beta)
report
#> Model ------------------------------------------------------------------
#>   method mhwg (exponential dual kernel): n = 80, p = 2, d = 3
#> Sampling ---------------------------------------------------------------
#>   2 chain(s), 800 iterations (warmup 400, thin 1); 800 retained draws
#>   total time 1.82s, mean MH acceptance 0.645
#> Coefficients (posterior summary + MCMC diagnostics) --------------------
#>  parameter    mean    sd   q2.5   q97.5   ess ess_per_sec rhat
#>  beta[1,1] -0.4820 0.182 -0.896 -0.1650 101.0        55.3 1.06
#>  beta[2,1] -1.1500 0.263 -1.760 -0.7270  92.4        50.7 1.44
#>  beta[1,2] -0.0445 0.207 -0.445  0.3750  33.1        18.2 1.16
#>  beta[2,2] -0.6640 0.314 -1.270 -0.0494  53.3        29.2 1.44
#>  beta[1,3]  0.6720 0.215  0.281  1.1200  86.8        47.6 1.09
#>  beta[2,3]  1.4900 0.283  0.939  2.0500  70.4        38.6 1.10
#>   worst split-Rhat 1.44; min ESS 33.1; min ESS/sec 18.2
#> Regression fit (training data) -----------------------------------------
#>   exact-match rate: 0.55  (fraction of rows with y_i == y_hat_i)
#>   per-coordinate accuracy: 0.688 0.775 0.800
#>   note: accuracy is bounded by the intrinsic utility noise; judge fit by the
#>   posterior predictive checks below, not by the raw rates.
#> Posterior predictive checks (50 replicates) ----------------------------
#>         statistic observed pred_mean pred_q5 pred_q95 p_value
#>       exact_match    0.550     0.553   0.450    0.644    1.00
#>       accuracy[1]    0.688     0.688   0.568    0.800    1.00
#>       accuracy[2]    0.775     0.753   0.681    0.825    0.76
#>       accuracy[3]    0.800     0.819   0.750    0.888    0.72
#>  marginal_freq[1]    0.462     0.414   0.338    0.500    0.44
#>  marginal_freq[2]    0.175     0.236   0.168    0.300    0.20
#>  marginal_freq[3]    0.425     0.406   0.362    0.462    0.64
#>   all observed statistics are typical of the fitted model
#> Estimation vs known truth ----------------------------------------------
#>   RMSE(beta): 0.202
#>   95% interval coverage: 1
#> Warnings ---------------------------------------------------------------
#>   - split-Rhat > 1.05 for 6 parameter(s): beta[1,1], beta[2,1], beta[1,2], beta[2,2], beta[1,3], ...
#>   - ESS < 100 for 5 parameter(s): beta[2,1], beta[1,2], beta[2,2], beta[1,3], beta[2,3]

The underlying per-parameter table is available as report$table, and the predictive-check object as report$ppc.

Reading the goodness-of-fit checks

The raw exact-match and accuracy rates are not by themselves evidence for or against the model: the response is the maximizer of a noisy utility, so even the true \(\beta\) cannot predict \(y_i\) perfectly — point-prediction accuracy is bounded by the intrinsic noise. The posterior predictive checks calibrate this. Each replicate re-simulates responses from the fitted model and recomputes the fit statistics; the model is adequate when the observed statistics are typical of that distribution (two-sided p-values away from 0). Here the observed exact-match rate sits in the middle of its predictive distribution — the seemingly modest rate is exactly what a correct model predicts for this noise level, and the RMSE and coverage against the known truth confirm accurate estimation. crr_ppc() runs the checks standalone with more replicates if needed.

Standalone ESS and split-Rhat

The two MCMC diagnostics that feed the report table are also exported directly. crr_ess() returns the effective sample size per coefficient — the number of independent draws the autocorrelated chain is worth. As a rough rule, 100–400 effective draws per quantity of interest is enough for stable posterior means and intervals; below ~100 the Monte Carlo error is no longer negligible (this is the threshold the report warns on).

crr_ess(fit)
#> beta[1,1] beta[2,1] beta[1,2] beta[2,2] beta[1,3] beta[2,3] 
#> 100.90047  92.39648  33.11390  53.28488  86.81648  70.39724

crr_rhat() returns the split-Rhat potential scale reduction factor, which compares within- to between-half-chain variance. Values at 1 indicate the chains have mixed to a common distribution; the usual cut-offs are 1.01 (strict) to 1.05 (lenient), above which the run has not converged and should be lengthened.

crr_rhat(fit)
#> beta[1,1] beta[2,1] beta[1,2] beta[2,2] beta[1,3] beta[2,3] 
#>  1.064611  1.435323  1.161338  1.438003  1.085274  1.104930

Here every coefficient clears both bars, consistent with the empty warnings block in the report above.

Standalone posterior predictive checks

crr_ppc() runs the goodness-of-fit checks on their own, and n_rep controls the number of replicates — more replicates sharpen the predictive distribution and stabilise the p-values at the cost of extra integer-program solves per replicate. The report uses 50 by default; a hundred or so is a reasonable standalone budget:

ppc <- crr_ppc(fit, n_rep = 100, seed = 1)
ppc
#> <crr_ppc>: 100 posterior predictive replicates
#>         statistic observed pred_mean pred_q5 pred_q95 p_value
#>       exact_match    0.550     0.555   0.449    0.662    1.00
#>       accuracy[1]    0.688     0.699   0.600    0.800    0.84
#>       accuracy[2]    0.775     0.757   0.687    0.838    0.80
#>       accuracy[3]    0.800     0.811   0.738    0.888    1.00
#>  marginal_freq[1]    0.462     0.417   0.337    0.488    0.46
#>  marginal_freq[2]    0.175     0.226   0.149    0.301    0.44
#>  marginal_freq[3]    0.425     0.408   0.350    0.462    0.80

The p-values are two-sided: each asks how extreme the observed statistic is within its own predictive distribution, so values far from 0 mean the observed data are typical of the fitted model. This is the formal version of the point made above — a modest raw exact-match rate is not evidence against the model when it sits comfortably inside the band the model itself predicts, whereas a p-value near 0 would flag genuine misfit. The observed statistic and its predictive mean and 5–95% band are in ppc$observed, ppc$replicated, and ppc$p_value for further inspection.

Reading the report table

The per-parameter table behind the printed summary is a plain data frame in report$table, with columns mean, sd, the interval bounds, ess, ess_per_sec, and rhat. Pulling it out lets you find the parameters that gate the run — sort by ascending ESS to see the slowest-mixing coordinates, or by descending Rhat for the least-converged:

tab <- report$table
head(tab[order(tab$ess), c("parameter", "ess", "ess_per_sec", "rhat")])
#>   parameter       ess ess_per_sec     rhat
#> 3 beta[1,2]  33.11390    18.15455 1.161338
#> 4 beta[2,2]  53.28488    29.21320 1.438003
#> 6 beta[2,3]  70.39724    38.59498 1.104930
#> 5 beta[1,3]  86.81648    47.59675 1.085274
#> 2 beta[2,1]  92.39648    50.65596 1.435323
#> 1 beta[1,1] 100.90047    55.31824 1.064611
head(tab[order(-tab$rhat), c("parameter", "rhat", "ess")])
#>   parameter     rhat       ess
#> 4 beta[2,2] 1.438003  53.28488
#> 2 beta[2,1] 1.435323  92.39648
#> 3 beta[1,2] 1.161338  33.11390
#> 6 beta[2,3] 1.104930  70.39724
#> 5 beta[1,3] 1.085274  86.81648
#> 1 beta[1,1] 1.064611 100.90047

When a report carries warnings (as in the harder matching example below), this is the practical way to see which coefficients are the bottleneck and by how much, rather than reading the truncated printout. The lightweight accessors summary(fit), coef(fit), fitted(fit), and residuals(fit) return the posterior summary data frame, the posterior-mean coefficient matrix, and the fitted / residual response matrices for the same kind of direct manipulation.

Plotting methods

All plots operate on the post-warmup, thinned draws.

Trace and autocorrelation:

plot(fit, type = "trace", pars = c("beta[1,1]", "beta[2,1]"))

plot(fit, type = "acf", pars = c("beta[1,1]", "beta[2,1]"))

Marginal posteriors of all coefficients as violins (median dot, central 95% interval bar):

plot(fit, type = "violin")

Effective sample sizes and sampling efficiency (ESS per second of sampling time, the efficiency measure used for method comparison in the paper):

plot(fit, type = "ess")

plot(fit, type = "ess_time")

Residual heat map of the posterior point-estimate fit (Y - fitted(fit), entries in \(\{-1, 0, 1\}\)):

plot(fit, type = "residual")

When the truth is known, the coefficient estimation error \(\hat\beta - \beta\) as a heat map:

plot(fit, type = "beta_diff", beta = sim$beta)

For anything beyond these built-ins, convert the draws with coda::as.mcmc(fit) or posterior::as_draws(fit) and use those ecosystems directly.

Benchmarking against the unconstrained baseline

crr_benchmark() fits several methods to the same data with identical settings and reports timing, efficiency, in-sample fit, and — when the true coefficients are known, as in simulation — RMSE and posterior-interval coverage:

bm <- crr_benchmark(sim$Y, sim$X, con, beta = sim$beta,
                    n_iter = 500, warmup = 250, seed = 42)
bm
#> <crr_benchmark>: 2 method(s), 95% intervals
#>         method time_sec accept_rate min_ess median_ess min_ess_per_sec
#>           mhwg    0.558       0.639    8.52       23.7            15.3
#>  unconstrained    0.036       1.000   23.60       71.1           656.0
#>  exact_match  rmse coverage
#>        0.562 0.250    0.833
#>        0.250 0.238    0.833

Two effects visible here reproduce the paper’s findings: the constrained sampler estimates \(\beta\) with smaller error and better-calibrated intervals, because the unconstrained probit baseline conditions on each coordinate independently and is biased when responses are in fact constrained maximizers.

The fitted models are kept in bm$fits, so any of the diagnostics above can be applied to each method afterwards, e.g. plot(bm$fits$unconstrained, type = "violin").

A non-trivial example: bipartite matching

The paper’s motivating application is matching data. Take a \(3 \times 3\) bipartite matching: \(y \in \{0,1\}^9\) indicates which of the nine possible edges \((l, r)\) are used, and each node may be matched at most once. The constraint matrix is the node–edge incidence matrix of \(K_{3,3}\) — totally unimodular by construction:

L <- 3; R <- 3
d <- L * R
A <- matrix(0, L + R, d)
for (l in seq_len(L)) {
  for (r in seq_len(R)) {
    edge <- (l - 1) * R + r
    A[l, edge] <- 1        # left node l matched at most once
    A[L + r, edge] <- 1    # right node r matched at most once
  }
}
con_match <- crr_constraints(A, b = rep(1, L + R))
con_match
#> <crr_constraints>: 6 constraints on {0,1}^9 (stored as A y <= b)

With \(d = 9\) coordinates, \(m = 6\) constraints, and \(p = 3\) covariates the posterior has 27 coefficients and the feasible set is the set of partial matchings — a substantially harder target than the toy problem above:

sim_m <- simulate_crr(n = 80, p = 3, constraints = con_match, seed = 7)
fit_m <- crr(sim_m$Y, sim_m$X, con_match,
             n_iter = 700, warmup = 350, chains = 2, seed = 7)
report_m <- crr_diagnostics(fit_m, beta = sim_m$beta)
print(report_m, max_rows = 8)
#> Model ------------------------------------------------------------------
#>   method mhwg (exponential dual kernel): n = 80, p = 3, d = 9
#> Sampling ---------------------------------------------------------------
#>   2 chain(s), 700 iterations (warmup 350, thin 1); 700 retained draws
#>   total time 2.98s, mean MH acceptance 0.631
#> Coefficients (posterior summary + MCMC diagnostics) --------------------
#>  parameter      mean    sd   q2.5  q97.5  ess ess_per_sec rhat
#>  beta[1,1] -0.000263 0.188 -0.338  0.385 47.8       16.00 1.02
#>  beta[2,1] -0.088200 0.201 -0.497  0.308 35.1       11.70 1.08
#>  beta[3,1] -0.107000 0.227 -0.536  0.306 31.8       10.70 1.17
#>  beta[1,2]  1.580000 0.281  1.090  2.130 32.5       10.90 1.29
#>  beta[2,2] -0.774000 0.235 -1.190 -0.302 40.6       13.60 1.09
#>  beta[3,2]  1.450000 0.247  0.969  1.950 19.0        6.36 1.09
#>  beta[1,3] -0.275000 0.234 -0.694  0.220 51.9       17.40 1.25
#>  beta[2,3]  0.137000 0.221 -0.284  0.561 24.1        8.06 1.09
#>   ... 19 more row(s); see $table
#>   worst split-Rhat 1.38; min ESS 11.1; min ESS/sec 3.7
#> Regression fit (training data) -----------------------------------------
#>   exact-match rate: 0.475  (fraction of rows with y_i == y_hat_i)
#>   per-coordinate accuracy: 0.863 0.875 0.800 0.912 0.887 0.863 0.875 0.887 0.825
#>   note: accuracy is bounded by the intrinsic utility noise; judge fit by the
#>   posterior predictive checks below, not by the raw rates.
#> Posterior predictive checks (50 replicates) ----------------------------
#>         statistic observed pred_mean pred_q5 pred_q95 p_value
#>       exact_match    0.475     0.422  0.3360    0.512    0.40
#>       accuracy[1]    0.862     0.863  0.8000    0.919    1.00
#>       accuracy[2]    0.875     0.869  0.8060    0.925    1.00
#>       accuracy[3]    0.800     0.798  0.7430    0.850    1.00
#>       accuracy[4]    0.912     0.880  0.8180    0.925    0.36
#>       accuracy[5]    0.888     0.896  0.8360    0.938    0.80
#>       accuracy[6]    0.862     0.832  0.7620    0.882    0.52
#>       accuracy[7]    0.875     0.834  0.7750    0.882    0.24
#>       accuracy[8]    0.888     0.849  0.8000    0.925    0.36
#>       accuracy[9]    0.825     0.830  0.7680    0.888    1.00
#>  marginal_freq[1]    0.138     0.138  0.0806    0.200    1.00
#>  marginal_freq[2]    0.425     0.406  0.3180    0.475    0.64
#>  marginal_freq[3]    0.288     0.289  0.2310    0.350    1.00
#>  marginal_freq[4]    0.338     0.390  0.3430    0.444    0.12
#>  marginal_freq[5]    0.125     0.110  0.0625    0.162    0.72
#>  marginal_freq[6]    0.325     0.325  0.2560    0.388    1.00
#>  marginal_freq[7]    0.138     0.155  0.0931    0.225    0.84
#>  marginal_freq[8]    0.312     0.304  0.2430    0.350    0.92
#>  marginal_freq[9]    0.238     0.259  0.1810    0.332    0.80
#>   all observed statistics are typical of the fitted model
#> Estimation vs known truth ----------------------------------------------
#>   RMSE(beta): 0.454
#>   95% interval coverage: 0.667
#> Warnings ---------------------------------------------------------------
#>   - split-Rhat > 1.05 for 21 parameter(s): beta[2,1], beta[3,1], beta[1,2], beta[2,2], beta[3,2], ...
#>   - ESS < 100 for 27 parameter(s): beta[1,1], beta[2,1], beta[3,1], beta[1,2], beta[2,2], ...

This report is doing its job: at this chain length the sampler has not converged — split-Rhat well above 1.05 and effective sample sizes in the tens are flagged, the MH acceptance rate is much lower than in the small problem, and the coefficient RMSE and interval coverage against the known truth are correspondingly degraded. Note the contrast with the posterior predictive checks, which still pass: the model is correct for these data (the package’s test suite verifies the sampler against exact enumerable posteriors), the chains are simply too short. This is the expected behaviour of the MH-within-Gibbs sampler as \(d\) grows — the paper’s production runs use long, heavily thinned chains (e.g. thinning by 25) precisely because of this autocorrelation, and the ESS-per-second plot is the tool for budgeting such runs:

plot(fit_m, type = "ess_time")

The coefficient-error heat map shows the estimation error concentrated in a few coordinates rather than spread uniformly — the signature of incomplete mixing rather than model bias:

plot(fit_m, type = "beta_diff", beta = sim_m$beta)

Even with short chains, ignoring the matching constraints costs far more than the remaining Monte Carlo error. The unconstrained baseline mixes fast (it is a conjugate Gibbs sampler) but converges to the wrong posterior:

bm_m <- crr_benchmark(sim_m$Y, sim_m$X, con_match, beta = sim_m$beta,
                      n_iter = 600, warmup = 300, seed = 7)
bm_m
#> <crr_benchmark>: 2 method(s), 95% intervals
#>         method time_sec accept_rate min_ess median_ess min_ess_per_sec
#>           mhwg    1.300       0.612    4.18       9.42            3.21
#>  unconstrained    0.131       1.000   20.00      87.50          152.00
#>  exact_match  rmse coverage
#>       0.5120 0.393    0.741
#>       0.0875 0.704    0.370

For production use on problems of this size, budget chain length from the ESS-per-second numbers above and rerun with n_iter and thin increased until the report’s Rhat and ESS warnings disappear — convergence at this dimension takes tens of thousands of iterations, which is why the report’s warnings, rather than the raw fit statistics, should drive that decision.