# Introduction to cosmic

This vignette uses data from the Seattle Police Department, the same data featured in Ridgeway (2026). The dataset is included with `cosmic`. Running the Stan model fit can take several hours, so the vignette renders from a small precomputed object that stores diagnostics, officer summaries, and selected posterior draws from the Seattle fit.

## Load the Seattle example

The demo data are distributed as a single data frame `d` in the package `extdata` directory.

    path <- system.file("extdata", "dataSPD.RData", package = "cosmic")
    if (!nzchar(path)) {
      path_candidates <- c(
        file.path(vignette_dir, "..", "inst", "extdata", "dataSPD.RData"),
        file.path("..", "inst", "extdata", "dataSPD.RData"),
        file.path("inst", "extdata", "dataSPD.RData")
      )
      path <- path_candidates[file.exists(path_candidates)][1]
    }

    if (is.na(path) || !nzchar(path)) {
      stop("Could not find 'dataSPD.RData' in the installed package or source tree.")
    }

    load(path)

Each row is an officer-incident record. `id` identifies the incident, `idOff` identifies the officer, and `y` is the officer’s highest observed force level on an ordinal scale from 1 to 4. Incidents with no within-incident variation in force have already been removed, since they do not contribute information to the conditional likelihood. The resulting dataset is already in the format expected by `cosmic()`: one row per officer within incident, with the outcome coded as consecutive integers starting at 1.

    seattle_overview <- data.frame(
      quantity = c("Officer-incident rows", "Incidents", "Officers", "Force levels"),
      value = c(nrow(d),
                length(unique(d$id)),
                length(unique(d$idOff)),
                length(unique(d$y))))
    seattle_overview
    #>                quantity value
    #> 1 Officer-incident rows 37016
    #> 2             Incidents  4821
    #> 3              Officers  1503
    #> 4          Force levels     4

    force_distribution <- data.frame(
      force_level = as.integer(names(table(d$y))),
      count = as.integer(table(d$y)))
    force_distribution
    #>   force_level count
    #> 1           1 28807
    #> 2           2  6243
    #> 3           3  1885
    #> 4           4    81

    officers_per_incident <- table(table(d$id))
    incident_size_distribution <- data.frame(
      officers_in_incident = as.integer(names(officers_per_incident)),
      number_of_incidents = as.integer(officers_per_incident)
    )
    incident_size_distribution
    #>    officers_in_incident number_of_incidents
    #> 1                     2                 190
    #> 2                     3                 406
    #> 3                     4                 581
    #> 4                     5                 630
    #> 5                     6                 523
    #> 6                     7                 427
    #> 7                     8                 378
    #> 8                     9                 308
    #> 9                    10                 258
    #> 10                   11                 244
    #> 11                   12                 183
    #> 12                   13                 174
    #> 13                   14                 127
    #> 14                   15                 108
    #> 15                   16                  99
    #> 16                   17                  64
    #> 17                   18                  70
    #> 18                   19                  51

## Fit the COSMIC model

To fit the conditional ordinal stereotype model, use the `cosmic()` function. This compiles the Stan program, prepares the Seattle data, and draws samples from the posterior distribution for the officer effects \(\boldsymbol\lambda\) and the ordinal score parameters \(\mathbf{s}\). The conditional likelihood calculation is parallelized across incidents, so `threads` controls within-chain parallelism. In this example it is usually better to let chains run sequentially (`cores = 1`) and use the available CPU budget inside each chain.

COSMIC fits models through `cmdstanr`, which is distributed through the Stan R-universe rather than CRAN. If `cmdstanr` or CmdStan is not already installed, run:

    install.packages("cmdstanr",
                     repos = c("https://stan-dev.r-universe.dev",
                               getOption("repos")))
    cmdstanr::install_cmdstan()

    # 5-6 hours?
    fit <- cosmic(d,
                  incidentID = id,
                  officerID  = idOff,
                  y          = y,
                  iter       = 10000,
                  chains     = 4,
                  cores      = 1,
                  threads    = 8)

## Check sampler diagnostics

After fitting, start by reviewing convergence and Hamiltonian Monte Carlo diagnostics. The most important questions are whether the chains mixed well, whether effective sample sizes are adequate, and whether the Hamiltonian Monte Carlo sampler encountered pathologies such as divergences.

    diagnose(fit)

    #> COSMIC fit diagnostics: OK
    #>   Max R-hat: 1.001
    #>   Min n_eff: 7172.822
    #>   Divergences: 0
    #>   Max treedepth saturations: 0
    #>   Low E-BFMI chains: none
    #> No major diagnostic problems detected.

## Summarize officers relative to their peers

The next step is to summarize each officer relative to their peer group. The officer-specific parameters \(\lambda_i\) are not individually identified; only contrasts between officers are meaningful. For that reason, `officer_summary()` reports each officer relative to a set of peers for whom the data are informative enough to estimate differences in \(\lambda\) with useful precision.

The prior standard deviation on the \(\lambda_i\) is set with `priorSD_lambda` when calling `cosmic()`, with the default equal to 2. The prior variance for the difference of \(\lambda_i\) and \(\lambda_j\) is `2*priorSD_lambda^2`. Officers \(i\) and \(j\) will be considered peers if their posterior variance, \(\mathrm{Var}(\lambda_i-\lambda_j\mid\mathrm{data}) < \mathtt{pct\_threshold} \times 2 \times \mathtt{priorSD\_lambda}^2\). `pct_threshold` is the percent reduction in variance in the difference between two officers’ values of \(\lambda\) needed in order for them to be considered peers, with the default set to 0.25. `officer_summary()` will also compute the probability that the officer is in the top or bottom `flag_officer_tail_pct` of the distribution of \(\lambda\) in their peer group, with the default set to 5%.

    flag_officer_tail_pct <- 0.05
    off_summary <- officer_summary(fit,
                                   pct_threshold = 0.25,
                                   pct_tail = flag_officer_tail_pct)

    head(off_summary)

    #>   idOffOrig idOff nPeers nInc force1 force2 force3 force4 pRankToppct
    #> 1         1     1    426    2      1      1      0      0     0.25785
    #> 2         2     2      0    1      1      0      0      0          NA
    #> 3         3     3    471    2      1      1      0      0     0.16500
    #> 4         4     4     74    5      5      0      0      0     0.00005
    #> 5         5     5    846   33     33      0      0      0     0.00000
    #> 6         6     6      0    2      2      0      0      0          NA
    #>   pRankBotpct      lamMean    lam025     lam975
    #> 1     0.10820  0.333570433 -2.377860  2.8710224
    #> 2          NA           NA        NA         NA
    #> 3     0.14960 -0.003180971 -2.683507  2.5165607
    #> 4     0.95380 -3.242193464 -6.172846 -0.7727395
    #> 5     0.95355 -3.510387239 -5.999311 -1.6184494
    #> 6          NA           NA        NA         NA

The resulting table includes the number of peers for each officer, incident counts, force-category counts, and posterior summaries of each officer’s latent propensity relative to peers. `lamMean` reports the posterior mean for the difference between \(\lambda_i\) and the mean of the \(\lambda\)s in officer \(i\)’s peer group. `lam025` and `lam975` give the endpoints of the 95% credible interval. Some officers have no reported peers because their force counts are too sparse for precise estimation of their \(\lambda\), or because they are weakly connected to the part of the officer network where pairwise contrasts can be estimated well.

The value of \(\lambda\) can be interpreted as a relative risk of using a more serious force level. \[ \frac{P(Y=y_2|\lambda=\lambda_0)}{P(Y=y_1|\lambda=\lambda_0)} = \frac{P(Y=y_2|\lambda=0)}{P(Y=y_1|\lambda=0)} e^{\left(s_{y_2}-s_{y_1}\right)\lambda_0} \] Since \(s_0=0\) and \(s_1=1\), one way of expressing \(\lambda\) is \[ e^{\lambda_0} = \frac{\frac{P(Y=1|\lambda=\lambda_0)}{P(Y=0|\lambda=\lambda_0)}} {\frac{P(Y=1|\lambda=0)}{P(Y=0|\lambda=0)}} \] The relative risk ratio of using Level 1 versus Level 0 force for an officer with \(\lambda=\lambda_0\) compared to the average of their peer group.

## Flag officers in the tails of the peer distribution

To focus attention on officers with strong posterior evidence of being unusually high or low relative to peers, filter the officer summary into an outlier report. `outlier_report()` keeps only those officers with posterior probability exceeding `prob_outlier` of being in either the top or bottom `pct_tail` of the peer-group distribution of \(\lambda\).

    outliers <- outlier_report(off_summary, prob_outlier = 0.9)
    head(outliers, 10)

    #>    idOffOrig idOff nPeers nInc force1 force2 force3 force4 pRankToppct
    #> 1         28    28   1170   37     12     24      1      0     0.99855
    #> 2        836   836   1188   75     33     39      2      1     0.99735
    #> 3       1450  1450    864    7      0      6      1      0     0.98625
    #> 4       1006  1006   1042    9      1      8      0      0     0.98010
    #> 5        182   182   1187   79     25     43     11      0     0.97370
    #> 6       1341  1341   1153   29      8     12      9      0     0.96065
    #> 7        517   517    104    5      5      0      0      0     0.00040
    #> 8       1447  1447    165    7      7      0      0      0     0.00035
    #> 9        603   603    179    4      4      0      0      0     0.00035
    #> 10       428   428     42    5      5      0      0      0     0.00030
    #>    pRankBotpct   lamMean    lam025     lam975
    #> 1      0.00000  3.001081  2.278690  3.7535805
    #> 2      0.00000  2.634561  2.119185  3.1536983
    #> 3      0.00000  3.705152  1.734301  6.0632103
    #> 4      0.00000  3.316528  1.781626  5.0991616
    #> 5      0.00000  2.445398  1.907371  2.9878141
    #> 6      0.00000  2.651643  1.778980  3.5635044
    #> 7      0.92700 -2.827792 -5.829419 -0.4328217
    #> 8      0.93600 -2.937077 -5.929507 -0.5594303
    #> 9      0.95665 -3.219114 -6.088955 -0.7492189
    #> 10     0.93415 -2.971142 -5.965520 -0.5442428

      outliers_display <- outliers
      prob_cols <- names(outliers_display) %in% c("pRankToppct", "pRankBotpct")
      tail_pct_label <- formatC(100 * flag_officer_tail_pct,
                                format = "fg", digits = 3)
      names(outliers_display)[names(outliers_display) == "pRankToppct"] <-
        paste0("Prob. top ", tail_pct_label, "%")
      names(outliers_display)[names(outliers_display) == "pRankBotpct"] <-
        paste0("Prob. bottom ", tail_pct_label, "%")
      outliers_display[prob_cols] <-
        lapply(outliers_display[prob_cols],
               function(x) sprintf("%.2f", x))

      digits <- rep(0, ncol(outliers_display))
      digits[seq.int(length(digits)-4,length(digits))] <- 2L

      knitr::kable(
        outliers_display,
        format = "html",
        digits = digits,
        caption = "Officers with strong posterior evidence of being in the tails of the peer distribution",
        align = rep("r", ncol(outliers_display))) |>
        kableExtra::kable_styling(
          bootstrap_options = c("striped", "hover", "condensed", "responsive"),
          full_width = FALSE,
          position = "center") |>
        kableExtra::row_spec(0, bold = TRUE, color = "white",
                             background = "#2C3E50") |>
        kableExtra::column_spec(1, bold = TRUE) |>
        kableExtra::scroll_box(width = "100%", height = "320px")

Officers with strong posterior evidence of being in the tails of the peer distribution
idOffOrig | idOff | nPeers | nInc | force1 | force2 | force3 | force4 | Prob. top 5% | Prob. bottom 5% | lamMean | lam025 | lam975
--- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | ---
28 | 28 | 1170 | 37 | 12 | 24 | 1 | 0 | 1.00 | 0.00 | 3.00 | 2.28 | 3.75
836 | 836 | 1188 | 75 | 33 | 39 | 2 | 1 | 1.00 | 0.00 | 2.63 | 2.12 | 3.15
1450 | 1450 | 864 | 7 | 0 | 6 | 1 | 0 | 0.99 | 0.00 | 3.71 | 1.73 | 6.06
1006 | 1006 | 1042 | 9 | 1 | 8 | 0 | 0 | 0.98 | 0.00 | 3.32 | 1.78 | 5.10
182 | 182 | 1187 | 79 | 25 | 43 | 11 | 0 | 0.97 | 0.00 | 2.45 | 1.91 | 2.99
1341 | 1341 | 1153 | 29 | 8 | 12 | 9 | 0 | 0.96 | 0.00 | 2.65 | 1.78 | 3.56
517 | 517 | 104 | 5 | 5 | 0 | 0 | 0 | 0.00 | 0.93 | -2.83 | -5.83 | -0.43
1447 | 1447 | 165 | 7 | 7 | 0 | 0 | 0 | 0.00 | 0.94 | -2.94 | -5.93 | -0.56
603 | 603 | 179 | 4 | 4 | 0 | 0 | 0 | 0.00 | 0.96 | -3.22 | -6.09 | -0.75
428 | 428 | 42 | 5 | 5 | 0 | 0 | 0 | 0.00 | 0.93 | -2.97 | -5.97 | -0.54
427 | 427 | 156 | 8 | 8 | 0 | 0 | 0 | 0.00 | 0.92 | -2.83 | -5.78 | -0.47
1027 | 1027 | 51 | 7 | 7 | 0 | 0 | 0 | 0.00 | 0.93 | -2.94 | -5.94 | -0.52
321 | 321 | 166 | 5 | 5 | 0 | 0 | 0 | 0.00 | 0.93 | -2.90 | -5.84 | -0.51
372 | 372 | 112 | 15 | 8 | 4 | 3 | 0 | 0.00 | 0.96 | -3.25 | -6.16 | -0.79
737 | 737 | 322 | 8 | 8 | 0 | 0 | 0 | 0.00 | 0.91 | -2.83 | -5.71 | -0.50
265 | 265 | 115 | 8 | 8 | 0 | 0 | 0 | 0.00 | 0.93 | -2.85 | -5.86 | -0.50
191 | 191 | 39 | 13 | 13 | 0 | 0 | 0 | 0.00 | 0.93 | -2.94 | -5.92 | -0.59
31 | 31 | 289 | 3 | 3 | 0 | 0 | 0 | 0.00 | 0.95 | -3.25 | -6.07 | -0.77
1201 | 1201 | 270 | 12 | 12 | 0 | 0 | 0 | 0.00 | 0.91 | -2.79 | -5.71 | -0.48
19 | 19 | 182 | 10 | 10 | 0 | 0 | 0 | 0.00 | 0.92 | -2.76 | -5.73 | -0.43
326 | 326 | 507 | 11 | 11 | 0 | 0 | 0 | 0.00 | 0.90 | -2.99 | -5.85 | -0.75
1356 | 1356 | 185 | 10 | 10 | 0 | 0 | 0 | 0.00 | 0.91 | -2.78 | -5.74 | -0.43
245 | 245 | 95 | 10 | 10 | 0 | 0 | 0 | 0.00 | 0.92 | -2.82 | -5.85 | -0.47
528 | 528 | 357 | 6 | 6 | 0 | 0 | 0 | 0.00 | 0.92 | -3.06 | -5.84 | -0.67
435 | 435 | 82 | 14 | 13 | 1 | 0 | 0 | 0.00 | 0.93 | -2.86 | -5.88 | -0.51
1178 | 1178 | 238 | 9 | 9 | 0 | 0 | 0 | 0.00 | 0.94 | -2.98 | -5.94 | -0.67
152 | 152 | 336 | 6 | 6 | 0 | 0 | 0 | 0.00 | 0.95 | -3.24 | -6.05 | -0.86
4 | 4 | 74 | 5 | 5 | 0 | 0 | 0 | 0.00 | 0.95 | -3.24 | -6.17 | -0.77
112 | 112 | 484 | 5 | 5 | 0 | 0 | 0 | 0.00 | 0.96 | -3.49 | -6.23 | -1.06
420 | 420 | 694 | 17 | 17 | 0 | 0 | 0 | 0.00 | 0.90 | -3.12 | -5.80 | -1.00
841 | 841 | 481 | 16 | 16 | 0 | 0 | 0 | 0.00 | 0.90 | -2.95 | -5.79 | -0.69
328 | 328 | 739 | 32 | 32 | 0 | 0 | 0 | 0.00 | 0.90 | -3.14 | -5.84 | -1.12
391 | 391 | 713 | 14 | 14 | 0 | 0 | 0 | 0.00 | 0.91 | -3.18 | -5.86 | -1.09
21 | 21 | 770 | 19 | 19 | 0 | 0 | 0 | 0.00 | 0.92 | -3.24 | -5.86 | -1.23
100 | 100 | 798 | 35 | 35 | 0 | 0 | 0 | 0.00 | 0.92 | -3.23 | -5.82 | -1.29
57 | 57 | 314 | 12 | 12 | 0 | 0 | 0 | 0.00 | 0.92 | -2.88 | -5.78 | -0.57
1407 | 1407 | 349 | 12 | 12 | 0 | 0 | 0 | 0.00 | 0.92 | -2.95 | -5.86 | -0.66
429 | 429 | 315 | 11 | 11 | 0 | 0 | 0 | 0.00 | 0.93 | -2.95 | -5.89 | -0.61
94 | 94 | 795 | 21 | 21 | 0 | 0 | 0 | 0.00 | 0.93 | -3.34 | -5.93 | -1.34
1461 | 1461 | 22 | 10 | 10 | 0 | 0 | 0 | 0.00 | 0.93 | -3.12 | -6.15 | -0.74
461 | 461 | 658 | 4 | 4 | 0 | 0 | 0 | 0.00 | 0.93 | -3.37 | -6.03 | -1.13
92 | 92 | 819 | 21 | 21 | 0 | 0 | 0 | 0.00 | 0.94 | -3.36 | -5.90 | -1.39
527 | 527 | 812 | 28 | 28 | 0 | 0 | 0 | 0.00 | 0.94 | -3.35 | -5.93 | -1.42
597 | 597 | 677 | 18 | 18 | 0 | 0 | 0 | 0.00 | 0.94 | -3.36 | -6.02 | -1.22
409 | 409 | 789 | 38 | 38 | 0 | 0 | 0 | 0.00 | 0.94 | -3.37 | -5.96 | -1.39
632 | 632 | 193 | 6 | 6 | 0 | 0 | 0 | 0.00 | 0.94 | -3.04 | -5.96 | -0.63
579 | 579 | 843 | 68 | 68 | 0 | 0 | 0 | 0.00 | 0.95 | -3.42 | -5.89 | -1.53
373 | 373 | 722 | 11 | 11 | 0 | 0 | 0 | 0.00 | 0.95 | -3.53 | -6.15 | -1.39
75 | 75 | 362 | 6 | 6 | 0 | 0 | 0 | 0.00 | 0.95 | -3.31 | -6.15 | -0.92
5 | 5 | 846 | 33 | 33 | 0 | 0 | 0 | 0.00 | 0.95 | -3.51 | -6.00 | -1.62
342 | 342 | 808 | 33 | 33 | 0 | 0 | 0 | 0.00 | 0.95 | -3.47 | -6.03 | -1.51
237 | 237 | 829 | 27 | 27 | 0 | 0 | 0 | 0.00 | 0.96 | -3.49 | -6.03 | -1.55
288 | 288 | 834 | 61 | 61 | 0 | 0 | 0 | 0.00 | 0.96 | -3.52 | -6.01 | -1.61
287 | 287 | 884 | 61 | 60 | 1 | 0 | 0 | 0.00 | 0.96 | -3.63 | -6.03 | -1.78
387 | 387 | 822 | 33 | 33 | 0 | 0 | 0 | 0.00 | 0.96 | -3.51 | -6.06 | -1.60
189 | 189 | 848 | 41 | 41 | 0 | 0 | 0 | 0.00 | 0.96 | -3.57 | -6.10 | -1.70
411 | 411 | 842 | 26 | 26 | 0 | 0 | 0 | 0.00 | 0.96 | -3.58 | -6.08 | -1.65
271 | 271 | 855 | 49 | 49 | 0 | 0 | 0 | 0.00 | 0.97 | -3.65 | -6.15 | -1.78
247 | 247 | 850 | 53 | 53 | 0 | 0 | 0 | 0.00 | 0.97 | -3.68 | -6.17 | -1.80
629 | 629 | 800 | 24 | 24 | 0 | 0 | 0 | 0.00 | 0.97 | -3.71 | -6.21 | -1.70
399 | 399 | 806 | 37 | 37 | 0 | 0 | 0 | 0.00 | 0.97 | -3.72 | -6.23 | -1.73
175 | 175 | 749 | 18 | 18 | 0 | 0 | 0 | 0.00 | 0.97 | -3.80 | -6.37 | -1.66
457 | 457 | 860 | 65 | 64 | 1 | 0 | 0 | 0.00 | 0.98 | -3.78 | -6.26 | -1.90
636 | 636 | 838 | 39 | 39 | 0 | 0 | 0 | 0.00 | 0.99 | -3.97 | -6.47 | -2.03

## Optional posterior extraction

If you need custom summaries or plots, extract posterior draws directly. In this model, the score parameters are anchored so that the baseline level is 0 and the next score is 1, while Stan samples the positive increments in `sDelta`. Under the notation used below, the remaining free score parameters are reconstructed as

\[ s_2 = 1 + \mathtt{sDelta}_1, \qquad s_3 = 1 + \mathtt{sDelta}_1 + \mathtt{sDelta}_2. \]

    sDelta_draws <- posterior(fit, pars = "sDelta")$sDelta

    score_summary <- data.frame(
      parameter = c("s[2]", "s[3]"),
      mean = c(
        mean(1 + sDelta_draws[, 1]),
        mean(1 + sDelta_draws[, 1] + sDelta_draws[, 2])
      ),
      lower_95 = c(
        unname(stats::quantile(1 + sDelta_draws[, 1], probs = 0.025)),
        unname(stats::quantile(1 + sDelta_draws[, 1] + sDelta_draws[, 2],
                               probs = 0.025))
      ),
      upper_95 = c(
        unname(stats::quantile(1 + sDelta_draws[, 1], probs = 0.975)),
        unname(stats::quantile(1 + sDelta_draws[, 1] + sDelta_draws[, 2],
                               probs = 0.975))
      )
    )

The table below summarizes the posterior distribution of the non-fixed score parameters. The expression \(\frac{1}{s_{y_2}-s_{y_1}}\log 2\) gives the amount that \(\lambda\) must increase to double the relative risk of force level \(y_2\) over \(y_1\).

Posterior summaries for the estimated score parameters
parameter | mean | lower_95 | upper_95
--- | --- | --- | ---
s[2] | 1.009 | 1.000 | 1.031
s[3] | 1.086 | 1.007 | 1.267

The next plots show the joint and marginal posterior distributions for these score parameters.

    ggplot(data.frame(x = sDelta_draws[,1],
                      y = sDelta_draws[,2]),
           aes(x=x, y=y)) +
      geom_point(alpha = 0.05, size = 0.5) +
      geom_density_2d(color = "red") +
      labs(x = expression(s[2] - 1),
           y = expression(s[3] - s[2])) +
      theme_minimal()

    hist(1+sDelta_draws[,1], xlab=expression(s[2]), main="")
    abline(v=1+mean(sDelta_draws[,1]))

    hist(1+sDelta_draws[,1]+sDelta_draws[,2],
         xlab=expression(s[3]), main="")
    abline(v=1+mean(sDelta_draws[,1])+mean(sDelta_draws[,2]))