# Set parameters
alpha <- .9
k_s <- 5
k <- 10
# Formula from above
new_alpha <- ((k_s/k)*alpha)/(1+((k_s/k)-1)*alpha)
round(new_alpha, 2)#> [1] 0.82
June 19, 2024
I guess every social scientist at one point has been confronted with a quite common dilemma: We know that we would need a quite large number of items to assess a certain latent concept comprehensively and validly, but we do not have enough space in our questionnaire to use the entire long scale or even inventory. More often than not, we then choose the “best” items (e.g., with the highest factor loadings). However, by reducing the number of items, we are likely reducing the scale’s validity as well. After all, we reduce the diversity and breadth of the manifest indicators that make up the scale.
An alternative to choosing a static subset of a scale that we administer to all participants in the exact same way is so-called item sampling. Here, the idea is to randomly select a subset of items for each respondent from a larger pool. This way, each participant answers a different subset of items. Due to the randomness in this sampling procedure, we can still gather sufficient information overall to estimate the latent trait for each individual participant. The question is, however, how many missing items per person are acceptable? And to what extent does the quality of the resulting scores depend on the size of the item pool, the size of the sample, and the original quality of the scale (e.g., as expressed by reliability indices such as Cronbach’s alpha)?
Classical Test Theory (CTT) offers a framework where each observed score (\(X\)) is conceptualized as the sum of a true score (\(T\)) and the error score (\(e\)):
\[X = T + e\]
In this context, the true score represents the actual level of the latent trait, and the error score encompasses all item-specific variance, measurement errors, or random noise.
CTT defines reliability as the proportion of variance in the observed scores that is attributable to the true scores. When items are randomly sampled, reliability can still be maintained by ensuring that the items are representative of the entire item pool and that the sampling process introduces minimal bias. The basic idea of item sampling is thus as follows:
Despite the reduction in the number of items per respondent, the randomness and overlap between different respondents’ subsets ensure that the overall information about the latent trait is preserved at the level of the sample (e.g., means, variances, and correlations of the scale). For each individual person, however, the precision of their score depends on how many items they answered, as the following sections show.
Given a large item pool, if we denote the total number of items by \(k\) and the number of items sampled for each respondent by \(k_s\), the reliability of the item-sampled test can be approximated by the Spearman-Brown prophecy formula (see e.g., de Vet et al., 2017)
\[\alpha_{new} = \frac{n*\alpha}{1+(n-1)*\alpha}\]
where:
By ensuring \(k_s/k\) is sufficiently large, the reliability of the scale can be preserved to a certain degree. But how large should these two parameters be? Let’s compute the reliability after sampling 5 items from a pool of 10 given an alpha of .9:
#> [1] 0.82
As we can see, the resulting reliability is still .82 (compared to the original .90), which may be completely sufficient for some contexts. Now, let’s explore the relationship between the ratio of sampled to total items and alpha more systematically. Yet, so far, we did not account for the impact of sample size. Further, the above formula only tells us whether the reliability can be preserved. What we also would like to know is whether the validity is preserved (e.g., whether the correlation between the short and the full scale is large).
The impact of sample size can be included by considering the standard error of the reliability estimate and the standard error of the correlation. Larger sample sizes typically result in more precise estimates. For \(k_s\) (essentially) parallel items, the standard error of Cronbach’s alpha is approximately (van Zyl, Neudecker, & Nel, 2000):
\[SE_{\alpha} \approx (1-\alpha)\sqrt{\frac{2k_s}{(k_s-1)N}}\]
where:
For parallel items, the expected correlation between the score based on the sampled items and the full-scale score follows from the same assumptions: \(r = \sqrt{\alpha_{new}/\alpha}\). Note that this is a part–whole correlation. The sampled items are part of the full scale, so both scores share not only the true score but also the measurement error of the shared items. Its standard error (SEr) can be approximated by:
\[SE_r = \frac{1-r^2}{\sqrt{N}}\]
where:
These standard errors indicate the precision of our reliability and correlation estimates. While the formulas for the reliability and validity provide point estimates, the standard errors show how these estimates would vary with different sample sizes.
So let’s put all these formulas in an R function:
library(tidyverse)
# Custom function to compute reliability and correlation
reli_new <- function(alpha, k_s, k, N = 100) {
# Ratio between sample items and total items
n <- k_s/k
# Spearman-Brown formula
alpha_new <- ((n)*alpha)/(1+((n)-1)*alpha)
# Standard error of alpha_new (van Zyl, Neudecker & Nel, 2000)
se_a <- (1 - alpha_new) * sqrt(2 * k_s / ((k_s - 1) * N))
# Correlation between scales (part-whole correlation for parallel items)
r <- sqrt(alpha_new/alpha)
se_r <- (1-r^2)/sqrt(N)
# Create output table
tibble(N = N,
k = k,
k_s = k_s,
n = n,
alpha = alpha,
alpha_new = alpha_new,
se_a = se_a,
r = r,
se_r = se_r)
}
# Check function
(check <- reli_new(alpha = .9, k_s = 5, k = 10, N = 100))#> # A tibble: 1 × 9
#> N k k_s n alpha alpha_new se_a r se_r
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 100 10 5 0.5 0.9 0.818 0.0287 0.953 0.00909
As we can see, given \(n\) = .5 and \(k\) = 10, an alpha level of \(\alpha\) = .9 and a sample size of \(N\) = 100, the reduced scale should have an alpha of \(\alpha_{new}\) = 0.82, 95%-CI [0.76, 0.87]. Further, the reduced scale should still correlate highly with the original scale: r = 0.95, 95%-CI [0.94, 0.97].
We can now compute these quantities across a range of conditions to explore the effect of item sampling more systematically:
# Transparent background and muted text, so the figures work in light and dark mode
theme_blog <- function() {
theme_minimal(base_size = 12) +
theme(plot.background = element_rect(fill = "transparent", colour = NA),
panel.background = element_rect(fill = "transparent", colour = NA),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = "#8a8f9840", linewidth = .4),
strip.text = element_text(colour = "#7a7f88", face = "bold"),
axis.text = element_text(colour = "#7a7f88"),
axis.title = element_text(colour = "#7a7f88"),
legend.text = element_text(colour = "#7a7f88"),
legend.title = element_text(colour = "#7a7f88"),
legend.position = "bottom")
}
blues <- c("#c4e4f6", "#8cc8ec", "#4aa8e0", "#1680bd", "#0b5a87") # sequential: alpha .75 -> .95# Expected reliability for a 20-item pool across sampling ratios and original alphas
analytic <- expand.grid(alpha = seq(.75, .95, by = .05), k_s = 2:20, N = c(100, 1000)) |>
pmap_dfr(reli_new, k = 20) |>
mutate(N = factor(paste("N =", format(N, big.mark = ",")), levels = c("N = 100", "N = 1,000")))
ggplot(analytic, aes(n, alpha_new, colour = factor(alpha), fill = factor(alpha))) +
geom_hline(yintercept = .8, linetype = "dashed", colour = "#8a8f98") +
geom_ribbon(aes(ymin = alpha_new - 1.96 * se_a, ymax = alpha_new + 1.96 * se_a),
alpha = .25, colour = NA) +
geom_line(linewidth = 1) +
facet_wrap(~ N) +
scale_colour_manual(values = blues) +
scale_fill_manual(values = blues) +
scale_x_continuous(labels = scales::percent) +
labs(x = "Share of items each person answers (k_s / k)",
y = "Expected reliability (alpha)",
colour = "Alpha of the full scale", fill = "Alpha of the full scale") +
theme_blog()We can see that the ability to “preserve” the reliability drops as the number of items per person decreases. It further decreases faster if the original reliability was already low. Yet, there are some interesting insights here: For example, if the original reliability is .9, we can “afford” up to 50% missings per person and still end up with a reliability of .80. If we only use a third of the items, we can still reach reliabilities of .75, which again may be acceptable in some contexts. The sample size does not change these expected values, it only affects how precisely we can estimate them.
ggplot(analytic, aes(n, r, colour = factor(alpha), fill = factor(alpha))) +
geom_hline(yintercept = c(.8, .9), linetype = "dashed", colour = "#8a8f98") +
geom_ribbon(aes(ymin = r - 1.96 * se_r, ymax = pmin(1, r + 1.96 * se_r)), alpha = .25, colour = NA) +
geom_line(linewidth = 1) +
facet_wrap(~ N) +
scale_colour_manual(values = blues) +
scale_fill_manual(values = blues) +
scale_x_continuous(labels = scales::percent) +
labs(x = "Share of items each person answers (k_s / k)",
y = "Expected correlation with the full scale",
colour = "Alpha of the full scale", fill = "Alpha of the full scale") +
theme_blog()With regard to the validity of the reduced scale, we can see that for scales with a reliability of at least .8, we can afford up to 50% missings and still have good convergent validity with the full scale (r > .90), and up to 70% missings if a validity of r > .80 is sufficient.
Bear in mind that this is just the result based on the formulas outlined above. Practically, however, more things may have to be taken into account:
To explore these issues a bit further, let’s simulate some data and introduce missings per person systematically.
The key idea of the simulation is to generate data for which we know each person’s true score. Then we can measure directly how well the scores based on the sampled items recover it: the squared correlation between a score and the true score is exactly the reliability of that score. This is the same quantity the Spearman–Brown formula predicts, so we can compare the two.
I simulate items from a one-factor model. First, I compute the average inter-item correlation that corresponds to the alpha level we are providing, and from it the factor loading of each item. Each person gets a true score \(\theta\), and each item response is \(\lambda\theta\) plus random error. To make the data look like a typical questionnaire, I cut the responses into 5 answer options. Because real items also differ in how easy they are to agree with, each item gets its own difficulty (a shift of the answer thresholds).
set.seed(123) # for reproducibility
sim_items <- function(N, k, alpha, answer_options = 5) {
# Average inter-item correlation implied by alpha, and the resulting loading
rbar <- alpha / (k - alpha * (k - 1))
lambda <- sqrt(rbar)
# True scores, item difficulties and continuous responses
theta <- rnorm(N)
difficulty <- runif(k, -.75, .75)
latent <- theta %o% rep(lambda, k) +
matrix(rnorm(N * k, sd = sqrt(1 - lambda^2)), N, k) -
matrix(difficulty, N, k, byrow = TRUE)
# Cut into Likert-type answer options (1 to 5)
cuts <- qnorm(seq(0, 1, length.out = answer_options + 1))[2:answer_options]
items <- matrix(findInterval(latent, cuts) + 1, N, k,
dimnames = list(NULL, paste0("i", 1:k)))
list(items = as.data.frame(items), theta = theta)
}
# Check function
test <- sim_items(N = 100, k = 10, alpha = .9)
head(test$items)#> i1 i2 i3 i4 i5 i6 i7 i8 i9 i10
#> 1 3 1 2 1 2 1 3 3 2 5
#> 2 2 1 1 2 2 1 4 3 5 4
#> 3 4 3 5 5 2 2 4 5 5 5
#> 4 3 4 4 2 4 2 4 4 4 5
#> 5 5 2 5 3 3 1 4 4 4 4
#> 6 5 4 5 5 5 4 5 2 5 5
As we can see, the function creates a data set with 10 items and n = 100 participants. Let’s check the alpha level of this scale:
#> raw_alpha std.alpha G6(smc) average_r S/N ase mean sd
#> 0.8482603 0.8487234 0.8451815 0.3594018 5.61041 0.02258244 3.164 0.8487091
#> median_r
#> 0.3625075
It is slightly lower than the specified .90. Cutting continuous responses into five categories always loses a bit of information. That’s why the simulation below compares everything with the alpha of the simulated (Likert-type) data, not with the input value.
Next, we need a function that simulates the item sampling procedure. A random item sampling procedure is the same as introducing a certain number of missings per person (i.e., per row): for each person, only a random subset of round(n * k) items is kept.
#> i1 i2 i3 i4 i5 i6 i7 i8 i9 i10
#> 1 3 1 2 1 NA 1 3 NA 2 5
#> 2 2 1 NA 2 2 1 NA 3 5 4
#> 3 4 3 5 5 2 NA 4 NA 5 5
#> 4 3 4 4 2 4 NA 4 4 4 NA
#> 5 5 2 5 3 3 1 4 NA 4 NA
#> 6 5 4 5 5 5 NA 5 2 5 NA
Here, I sample 80% of the items per person (i.e., 2 of the 10 items are missing). In fact, this corresponds to an overall amount of missings in the data set of 20%:
How do we compute a score for each person if everyone answered different items? I compare three approaches:
mice), and factor scores are then computed from a CFA on the completed data.score_mean <- function(items) rowMeans(items, na.rm = TRUE)
score_adjusted <- function(items) {
centered <- sweep(as.matrix(items), 2, colMeans(items, na.rm = TRUE))
rowMeans(centered, na.rm = TRUE)
}
score_cfa <- function(items) {
model <- paste("G =~", paste(names(items), collapse = " + "))
scores <- as.numeric(lavPredict(cfa(model, data = items, std.lv = TRUE)))
# The sign of a factor is arbitrary: align it with the sum score
# (all items are positively keyed)
if (cor(scores, rowSums(items)) < 0) scores <- -scores
scores
}
score_imputed <- function(items) {
score_cfa(complete(mice(items, m = 1, printFlag = FALSE)))
}
# Reliability of each score = squared correlation with the true score
sapply(list(mean = score_mean(test_na),
adjusted = score_adjusted(test_na),
imputed = score_imputed(test_na)),
\(score) cor(score, test$theta)^2) |> round(2)#> mean adjusted imputed
#> 0.79 0.80 0.76
Now, we put it all together in one function that simulates a data set, samples the items, scores the data in all three ways, and computes two outcomes for each score:
It also stores the alpha of the full simulated scale and the reliability the Spearman–Brown formula predicts from it.
alpha_full <- function(items) {
k <- ncol(items)
k / (k - 1) * (1 - sum(apply(items, 2, var)) / var(rowSums(items)))
}
sim_func <- function(N, k, alpha, n) {
d <- sim_items(N, k, alpha)
sampled <- sample_items(d$items, n)
a <- alpha_full(d$items)
full <- list(mean = score_mean(d$items), adjusted = score_mean(d$items),
imputed = score_cfa(d$items))
part <- list(mean = score_mean(sampled), adjusted = score_adjusted(sampled),
imputed = score_imputed(sampled))
tibble(N = N, k = k, k_s = round(n * k), n = n, alpha = alpha, alpha_emp = a,
method = names(part),
r_full = mapply(cor, part, full),
reliability = sapply(part, cor, y = d$theta)^2,
reliability_full = sapply(full, cor, y = d$theta)^2,
spearman_brown = n * a / (1 + (n - 1) * a))
}
# Check sim function
sim_func(N = 500, k = 20, alpha = .85, n = .5)#> # A tibble: 3 × 11
#> N k k_s n alpha alpha_emp method r_full reliability
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr> <dbl> <dbl>
#> 1 500 20 10 0.5 0.85 0.823 mean 0.912 0.684
#> 2 500 20 10 0.5 0.85 0.823 adjusted 0.926 0.707
#> 3 500 20 10 0.5 0.85 0.823 imputed 0.858 0.607
#> # ℹ 2 more variables: reliability_full <dbl>, spearman_brown <dbl>
We set up a grid that contains the parameter ranges we want to explore and run the function 200 times for each of the 270 combinations. That is 54,000 simulated data sets, each scored in three ways. I use the package furrr to parallelize the procedure; furrr_options(seed = ...) makes the parallel random numbers reproducible.
# Setup simulation grid
setup <- expand.grid(
N = c(250, 500, 1000),
k = c(10, 20, 40),
alpha = seq(.75, .95, by = .05),
n = c(.3, .4, .5, .6, .7, .8)
)
# The simulation takes a while: its results are saved next to this post and
# re-used (delete sims/results.rds to run it again)
n_sim <- 200
if (file.exists("sims/results.rds")) {
results <- readRDS("sims/results.rds")
} else {
plan(multisession, workers = parallelly::availableCores(omit = 1))
results <- setup[rep(seq_len(nrow(setup)), n_sim), ] |>
future_pmap_dfr(sim_func, .options = furrr_options(seed = 2024))
dir.create("sims", showWarnings = FALSE)
saveRDS(results, "sims/results.rds")
}
results#> # A tibble: 162,000 × 11
#> N k k_s n alpha alpha_emp method r_full reliability
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr> <dbl> <dbl>
#> 1 250 10 3 0.3 0.75 0.771 mean 0.780 0.408
#> 2 250 10 3 0.3 0.75 0.771 adjusted 0.811 0.451
#> 3 250 10 3 0.3 0.75 0.771 imputed 0.647 0.297
#> 4 500 10 3 0.3 0.75 0.718 mean 0.740 0.397
#> 5 500 10 3 0.3 0.75 0.718 adjusted 0.779 0.436
#> 6 500 10 3 0.3 0.75 0.718 imputed 0.508 0.165
#> 7 1000 10 3 0.3 0.75 0.727 mean 0.753 0.410
#> 8 1000 10 3 0.3 0.75 0.727 adjusted 0.781 0.435
#> 9 1000 10 3 0.3 0.75 0.727 imputed 0.597 0.251
#> 10 250 20 6 0.3 0.75 0.741 mean 0.777 0.438
#> # ℹ 161,990 more rows
#> # ℹ 2 more variables: reliability_full <dbl>, spearman_brown <dbl>
The first figure shows the reliability of each person’s score, depending on the share of items they answered and the alpha of the full scale (for a sample of 500 and a pool of 20 items). The dashed line is the reliability predicted by the Spearman–Brown formula.
d <- filter(summ, N == 500, k == 20)
ggplot(d, aes(n, rel, colour = method)) +
geom_line(aes(y = sb), colour = "#8a8f98", linetype = "dashed", linewidth = .8) +
geom_pointrange(aes(ymin = rel_lo, ymax = rel_hi), size = .2,
position = position_dodge(width = .04)) +
geom_line(position = position_dodge(width = .04)) +
facet_wrap(~ lab, nrow = 1) +
scale_colour_manual(values = method_cols, labels = method_labels) +
scale_x_continuous(labels = scales::percent, breaks = c(.3, .5, .7)) +
labs(x = "Share of items each person answers",
y = "Reliability of the person scores",
colour = NULL,
caption = "Points: mean of 200 replications, bars: 95% of replications. Dashed: Spearman–Brown prediction.") +
theme_blog() +
theme(plot.caption = element_text(colour = "#7a7f88"))Three things stand out:
Next, we can look at the correlation between the item-sampled scores and the scores based on all items. The dashed line shows the part–whole correlation expected from the formulas above.
ggplot(d, aes(n, r, colour = method)) +
geom_line(aes(y = sqrt(sb / alpha_emp)), colour = "#8a8f98", linetype = "dashed", linewidth = .8) +
geom_hline(yintercept = c(.8, .9), colour = "#8a8f9860") +
geom_pointrange(aes(ymin = r_lo, ymax = r_hi), size = .2,
position = position_dodge(width = .04)) +
geom_line(position = position_dodge(width = .04)) +
facet_wrap(~ lab, nrow = 1) +
scale_colour_manual(values = method_cols, labels = method_labels) +
scale_x_continuous(labels = scales::percent, breaks = c(.3, .5, .7)) +
labs(x = "Share of items each person answers",
y = "Correlation with the full-scale score",
colour = NULL) +
theme_blog()The picture is the same: the mean-based scores correlate with the full-scale scores as expected, the imputation-based scores less so. If the original scale has a high reliability (alpha ≥ .85) and people answer at least half of the items, the item-sampled scores correlate r > .90 with the full scale.
Interestingly, sample size and the size of the item pool hardly matter:
summ |>
filter(method == "adjusted", alpha == .85, n == .5) |>
ggplot(aes(factor(N), rel, colour = factor(k))) +
geom_hline(aes(yintercept = mean(sb)), colour = "#8a8f98", linetype = "dashed") +
geom_pointrange(aes(ymin = rel_lo, ymax = rel_hi), position = position_dodge(width = .5)) +
scale_colour_manual(values = c("10" = "#8cc8ec", "20" = "#1497d6", "40" = "#0b5a87")) +
labs(x = "Sample size", y = "Reliability", colour = "Items in the pool") +
theme_blog()For a given alpha of the full scale and a given share of items answered, the expected reliability is the same for 250 or 1,000 respondents and for pools of 10 or 40 items. A larger sample only makes the result less variable. (Note that in this simulation, a larger pool with the same alpha means weaker individual items. In practice, extending a scale usually increases its alpha, and then a larger pool does help.)
Let’s look at one example (N = 500, k = 20, 12 sampled items, alpha = .80):
#> # A tibble: 3 × 5
#> method alpha_emp `Spearman-Brown` reliability r_full
#> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Difficulty-adjusted mean 0.768 0.665 0.663 0.931
#> 2 Imputation (PMM) + factor scores 0.768 0.665 0.555 0.855
#> 3 Mean of answered items 0.768 0.665 0.646 0.918
# Mathematical results (with the alpha of the simulated Likert-type data)
a <- unique(round(filter(summ, N == 500, k == 20, n == .6, alpha == .8)$alpha_emp, 3))
reli_new(alpha = a, k_s = 12, k = 20, N = 500) |>
mutate(ll = r - 1.96*se_r,
ul = r + 1.96*se_r) |>
dplyr::select(N, k, k_s, alpha, alpha_new, r, ll, ul)#> # A tibble: 1 × 8
#> N k k_s alpha alpha_new r ll ul
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 500 20 12 0.768 0.665 0.931 0.919 0.942
When we measure reliability as the precision of the person scores and use the alpha of the simulated Likert-type data, the simulation and the mathematical approach agree closely for the two mean-based scores.
So what does this all mean? From my point of view, the simulations indicate that item sampling is a viable strategy for efficiently measuring latent traits in social science research (e.g., scales of 10-40 items with Likert-type items):
This technique reduces respondent burden and shortens questionnaires. At the level of the sample (e.g., means and correlations of the scale), item sampling preserves the information of the full item pool. At the level of individuals, the reliability of their scores follows Spearman–Brown, and that is something we can plan for.
#> R version 4.4.0 (2024-04-24)
#> Platform: aarch64-apple-darwin20
#> Running under: macOS 26.6.2
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.0
#>
#> locale:
#> [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
#>
#> time zone: Europe/Amsterdam
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] furrr_0.3.1 future_1.70.0 mice_3.16.0 lavaan_0.6-19
#> [5] lubridate_1.9.3 forcats_1.0.0 stringr_1.6.0 dplyr_1.2.0
#> [9] purrr_1.2.1 readr_2.1.5 tidyr_1.3.2 tibble_3.3.1
#> [13] ggplot2_4.0.2 tidyverse_2.0.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 shape_1.4.6.1 xfun_0.57 htmlwidgets_1.6.4
#> [5] psych_2.4.3 lattice_0.22-6 tzdb_0.4.0 quadprog_1.5-8
#> [9] vctrs_0.7.2 tools_4.4.0 Rdpack_2.6.6 generics_0.1.4
#> [13] parallel_4.4.0 stats4_4.4.0 pan_1.9 pkgconfig_2.0.3
#> [17] jomo_2.7-6 Matrix_1.7-0 RColorBrewer_1.1-3 S7_0.2.1
#> [21] lifecycle_1.0.5 compiler_4.4.0 farver_2.1.2 mnormt_2.1.1
#> [25] codetools_0.2-20 htmltools_0.5.8.1 yaml_2.3.12 glmnet_4.1-8
#> [29] nloptr_2.2.1 pillar_1.11.1 MASS_7.3-60.2 reformulas_0.4.4
#> [33] iterators_1.0.14 rpart_4.1.23 boot_1.3-30 foreach_1.5.2
#> [37] mitml_0.4-5 parallelly_1.46.1 nlme_3.1-164 tidyselect_1.2.1
#> [41] digest_0.6.39 stringi_1.8.7 listenv_0.10.1 labeling_0.4.3
#> [45] splines_4.4.0 fastmap_1.2.0 grid_4.4.0 cli_3.6.5
#> [49] magrittr_2.0.4 utf8_1.2.6 survival_3.5-8 broom_1.0.12
#> [53] pbivnorm_0.6.0 withr_3.0.2 scales_1.4.0 backports_1.5.0
#> [57] timechange_0.3.0 rmarkdown_2.30 globals_0.19.1 nnet_7.3-19
#> [61] lme4_2.0-1 hms_1.1.3 evaluate_1.0.5 knitr_1.51
#> [65] rbibutils_2.4.1 rlang_1.1.7 Rcpp_1.1.1 glue_1.8.0
#> [69] minqa_1.2.8 jsonlite_2.0.0 R6_2.6.1