Naive measures of learning—the difference between post-test and pre-test scores—systematically underestimate actual learning. The reason is straightforward: people who don’t know the answer to a closed-ended question often guess, and guessing inflates scores. Since pre-test scores contain more guessing (people know less before the informative process), the bias is larger in the pre-test. The difference thus attenuates the true learning effect.
This vignette validates the implementation of the latent class model for measuring learning against the theoretical framework from Cor and Sood. We first walk through a typical analysis workflow, then derive the cell probability formulas from first principles, verify that the implementation matches these derivations, and demonstrate parameter recovery through simulation.
This section demonstrates a complete analysis workflow from raw data to final estimates.
Your data should be two data frames with the same dimensions: -
pre_test: Pre-test responses (0 = wrong, 1 = correct,
optionally “d” for Don’t Know) - post_test: Post-test
responses (same coding)
Each row is a respondent, each column is an item.
Before applying the LCA correction, compute the naive estimate for comparison:
naive_learning <- colMeans(post_test) - colMeans(pre_test)
cat("Naive learning estimates (biased downward):\n")
#> Naive learning estimates (biased downward):
print(round(naive_learning, 3))
#> item1 item2 item3
#> 0.3 0.3 0.2
cat(sprintf("\nMean naive learning: %.3f\n", mean(naive_learning)))
#>
#> Mean naive learning: 0.267Use item_lca_fit() to estimate the latent class
proportions:
fit <- item_lca_fit(pre_test, post_test)
print(fit)
#> LCA Model Fit
#> ----------------------------------------
#> Items: 3 | Observations: 30
#> Model: Without Don't Know
#>
#> Learning estimates:
#> item1 item2 item3
#> 0.3 0.3 0.2
#>
#> Use summary() for parameter details, coef() to extract parameters.The key output is the gk parameter—the proportion who
learned:
summary(fit)
#> LCA Model Summary
#> ==================================================
#> Items: 3 | Observations: 30
#> Model: Without Don't Know
#>
#> Parameter Estimates:
#> --------------------------------------------------
#> item1 item2 item3
#> gg 0.3 0.3 0.3
#> gk 0.3 0.3 0.2
#> kk 0.4 0.4 0.5
#> gamma 0.0 0.0 0.0
#>
#> Learning Estimates (gk):
#> --------------------------------------------------
#> item1 item2 item3
#> 0.3 0.3 0.2
#>
#> Mean learning: 0.2667
cat("\nComparison: Naive vs. LCA-adjusted learning:\n")
#>
#> Comparison: Naive vs. LCA-adjusted learning:
comparison <- data.frame(
Item = colnames(pre_test),
Naive = naive_learning,
LCA_Adjusted = fit$learning,
Difference = fit$learning - naive_learning
)
print(comparison, row.names = FALSE)
#> Item Naive LCA_Adjusted Difference
#> item1 0.3 0.3 -5.071168e-09
#> item2 0.3 0.3 -5.071168e-09
#> item3 0.2 0.2 -2.032310e-08The LCA-adjusted estimates should be larger than naive estimates because they correct for the downward bias from guessing.
For inference, use lca_se() to obtain bootstrapped
standard errors:
Check how well the model fits each item:
fit_stats <- fit_model(
pre_test, post_test,
fit$params["gamma", ],
fit$params[c("gg", "gk", "kk"), ]
)
print(fit_stats)
#> item1 item2 item3
#> chi-square NA NA NA
#> p-value NA NA NANon-significant p-values (> 0.05) indicate adequate fit.
To compare learning between groups, fit the model separately:
group <- c(rep("treatment", 5), rep("control", 5))
pre_treat <- pre_test[group == "treatment", ]
post_treat <- post_test[group == "treatment", ]
pre_ctrl <- pre_test[group == "control", ]
post_ctrl <- post_test[group == "control", ]
fit_treat <- item_lca_fit(pre_treat, post_treat)
fit_ctrl <- item_lca_fit(pre_ctrl, post_ctrl)
cat("Treatment group learning:", round(mean(fit_treat$learning), 3), "\n")
#> Treatment group learning: 0.333
cat("Control group learning:", round(mean(fit_ctrl$learning), 3), "\n")
#> Control group learning: 0.2
cat("Difference:", round(mean(fit_treat$learning) - mean(fit_ctrl$learning), 3), "\n")
#> Difference: 0.133The model assumes three mutually exclusive latent classes representing knowledge states before and after an informative process:
| Class | Name | Interpretation |
|---|---|---|
| gg | guess-guess | Did not know before, did not know after (stable ignorance) |
| gk | guess-know | Did not know before, knows after (LEARNED) |
| kk | know-know | Knew before, knows after (stable knowledge) |
The key simplifying assumption is no forgetting: we rule out know-to-guess transitions. This is reasonable for short-term learning interventions where forgetting is unlikely.
Let \(\gamma\) denote the probability of answering correctly when guessing. For a K-option multiple choice question with random guessing, \(\gamma = 1/K\).
On a pre-post test, we observe a 2×2 transition matrix:
| Pre Post | 0 (Wrong) | 1 (Right) |
|---|---|---|
| 0 (Wrong) | \(n_{00}\) | \(n_{01}\) |
| 1 (Right) | \(n_{10}\) | \(n_{11}\) |
Each latent class generates a specific pattern of responses:
Class gg (guess both times):
Class gk (guess pre, know post):
Class kk (know both times):
The probability of each observable cell is the sum over latent classes:
\[ \begin{aligned} P(0 \to 0) &= gg \cdot (1-\gamma)(1-\gamma) \\ &= (1-\gamma)^2 \cdot gg \end{aligned} \]
\[ \begin{aligned} P(0 \to 1) &= gg \cdot (1-\gamma)\gamma + gk \cdot (1-\gamma) \cdot 1 \\ &= (1-\gamma)\gamma \cdot gg + (1-\gamma) \cdot gk \end{aligned} \]
\[ \begin{aligned} P(1 \to 0) &= gg \cdot \gamma(1-\gamma) \\ &= (1-\gamma)\gamma \cdot gg \end{aligned} \]
\[ \begin{aligned} P(1 \to 1) &= gg \cdot \gamma \cdot \gamma + gk \cdot \gamma \cdot 1 + kk \cdot 1 \cdot 1 \\ &= \gamma^2 \cdot gg + \gamma \cdot gk + kk \end{aligned} \]
Suppose the true parameters are \(gg = 0.35\), \(gk = 0.30\), \(kk = 0.35\), and \(\gamma = 0.25\).
gg <- 0.35
gk <- 0.30
kk <- 0.35
gamma <- 0.25
p00 <- (1 - gamma)^2 * gg
p01 <- (1 - gamma) * gamma * gg + (1 - gamma) * gk
p10 <- (1 - gamma) * gamma * gg
p11 <- gamma^2 * gg + gamma * gk + kk
cat("Cell probabilities:\n")
#> Cell probabilities:
cat(sprintf(" P(0→0) = %.4f\n", p00))
#> P(0→0) = 0.1969
cat(sprintf(" P(0→1) = %.4f\n", p01))
#> P(0→1) = 0.2906
cat(sprintf(" P(1→0) = %.4f\n", p10))
#> P(1→0) = 0.0656
cat(sprintf(" P(1→1) = %.4f\n", p11))
#> P(1→1) = 0.4469
cat(sprintf(" Sum = %.4f\n", p00 + p01 + p10 + p11))
#> Sum = 1.0000Note that the probabilities sum to 1, as they should.
The model has:
This gives 4 observations to estimate 3 free parameters. The model is just-identified: we have exactly enough information to solve for the parameters, but no degrees of freedom for goodness-of-fit tests within a single item.
The guess package implements the likelihood function in
guess_lik(). Let’s verify it matches our derivation:
guess_lik_manual <- function(gg, gk, kk, gamma, data) {
vec <- numeric(4)
vec[1] <- (1 - gamma) * (1 - gamma) * gg # P(0→0)
vec[2] <- (1 - gamma) * gamma * gg + (1 - gamma) * gk # P(0→1)
vec[3] <- (1 - gamma) * gamma * gg # P(1→0)
vec[4] <- gamma * gamma * gg + gamma * gk + kk # P(1→1)
-sum(data * log(vec))
}
test_data <- c(100, 150, 50, 200)
ll_manual <- guess_lik_manual(0.35, 0.30, 0.35, 0.25, test_data)
cat(sprintf("Manual implementation: %.4f\n", ll_manual))
#> Manual implementation: 645.1621The strongest test of any estimator is whether it can recover known parameters from simulated data. We simulate data with known parameters and check if the fitted model recovers them.
true_params <- c(gg = 0.35, gk = 0.30, kk = 0.35, gamma = 0.25)
sim <- simulate_lca(
n = 1000,
n_items = 5,
gg = true_params["gg"],
gk = true_params["gk"],
kk = true_params["kk"],
gamma = true_params["gamma"],
seed = 123
)
fit <- item_lca_fit(sim$pre, sim$post)
estimated <- c(
gg = mean(fit$params["gg", ]),
gk = mean(fit$params["gk", ]),
kk = mean(fit$params["kk", ]),
gamma = mean(fit$params["gamma", ])
)
comparison <- rbind(
true = true_params,
estimated = estimated,
difference = estimated - true_params
)
knitr::kable(comparison,
digits = 3,
caption = "Parameter Recovery: True vs. Estimated"
)| gg | gk | kk | gamma | |
|---|---|---|---|---|
| true | 0.350 | 0.300 | 0.350 | 0.250 |
| estimated | 0.342 | 0.302 | 0.356 | 0.241 |
| difference | -0.008 | 0.002 | 0.006 | -0.009 |
The estimated parameters should be close to the true values. Small differences are expected due to sampling variability.
A single simulation might be lucky. To properly validate the estimator, we run many simulations and examine the distribution of estimates.
n_sims <- 100
n <- 500
n_items <- 2
true_params <- c(gg = 0.35, gk = 0.30, kk = 0.35, gamma = 0.25)
set.seed(789)
estimates <- matrix(NA, nrow = n_sims, ncol = 4)
colnames(estimates) <- names(true_params)
for (sim in seq_len(n_sims)) {
sim_data <- simulate_lca(
n = n, n_items = n_items,
gg = true_params["gg"], gk = true_params["gk"],
kk = true_params["kk"], gamma = true_params["gamma"]
)
tryCatch(
{
fit <- item_lca_fit(sim_data$pre, sim_data$post)
estimates[sim, ] <- c(
mean(fit$params["gg", ]),
mean(fit$params["gk", ]),
mean(fit$params["kk", ]),
mean(fit$params["gamma", ])
)
},
error = function(e) NULL
)
}An unbiased estimator has \(E[\hat{\theta}] = \theta\). We check this by comparing mean estimates to true values:
mean_estimates <- colMeans(estimates, na.rm = TRUE)
bias <- mean_estimates - true_params
rel_bias <- 100 * bias / true_params
bias_table <- data.frame(
Parameter = names(true_params),
True = true_params,
Mean_Estimate = mean_estimates,
Bias = bias,
Relative_Bias_Pct = rel_bias
)
knitr::kable(bias_table,
digits = 4, row.names = FALSE,
caption = "Bias Assessment from Monte Carlo Simulation"
)| Parameter | True | Mean_Estimate | Bias | Relative_Bias_Pct |
|---|---|---|---|---|
| gg | 0.35 | 0.3508 | 0.0008 | 0.2196 |
| gk | 0.30 | 0.2961 | -0.0039 | -1.3012 |
| kk | 0.35 | 0.3531 | 0.0031 | 0.8957 |
| gamma | 0.25 | 0.2532 | 0.0032 | 1.2690 |
The bias should be close to zero for all parameters.
The standard deviation of estimates across simulations approximates the true standard error. For well-behaved estimators, this should decrease with \(\sqrt{n}\):
se_estimates <- apply(estimates, 2, sd, na.rm = TRUE)
rmse <- sqrt(colMeans(
(estimates - matrix(true_params,
nrow = n_sims,
ncol = 4, byrow = TRUE
))^2,
na.rm = TRUE
))
se_table <- data.frame(
Parameter = names(true_params),
SE = se_estimates,
RMSE = rmse
)
knitr::kable(se_table,
digits = 4, row.names = FALSE,
caption = "Standard Errors from Monte Carlo Simulation"
)| Parameter | SE | RMSE |
|---|---|---|
| gg | 0.0236 | 0.0235 |
| gk | 0.0289 | 0.0290 |
| kk | 0.0290 | 0.0291 |
| gamma | 0.0259 | 0.0260 |
For valid confidence intervals, 95% CIs should contain the true parameter 95% of the time:
coverage <- numeric(4)
for (j in 1:4) {
ci_lower <- estimates[, j] - 1.96 * se_estimates[j]
ci_upper <- estimates[, j] + 1.96 * se_estimates[j]
coverage[j] <- mean(true_params[j] >= ci_lower &
true_params[j] <= ci_upper, na.rm = TRUE)
}
coverage_table <- data.frame(
Parameter = names(true_params),
Coverage_95 = coverage
)
knitr::kable(coverage_table,
digits = 3, row.names = FALSE,
caption = "95% CI Coverage from Monte Carlo Simulation"
)| Parameter | Coverage_95 |
|---|---|
| gg | 0.98 |
| gk | 0.94 |
| kk | 0.93 |
| gamma | 0.95 |
Coverage should be approximately 0.95 for all parameters.
hist(estimates[, "gk"],
breaks = 20,
main = "Distribution of Learning (gk) Estimates",
xlab = "Estimated gk", col = "lightblue", border = "white"
)
abline(v = true_params["gk"], col = "red", lwd = 2, lty = 2)
legend("topright", legend = c("True value"), col = "red", lty = 2, lwd = 2)The precision of estimates should improve with sample size, following the \(1/\sqrt{n}\) rule:
sample_sizes <- c(100, 250, 500, 1000)
true_params <- c(gg = 0.35, gk = 0.30, kk = 0.35, gamma = 0.25)
n_sims_quick <- 50
set.seed(456)
rmse_by_n <- matrix(NA, nrow = length(sample_sizes), ncol = 4)
colnames(rmse_by_n) <- names(true_params)
for (s in seq_along(sample_sizes)) {
n <- sample_sizes[s]
estimates_n <- matrix(NA, nrow = n_sims_quick, ncol = 4)
for (sim in seq_len(n_sims_quick)) {
sim_data <- simulate_lca(
n = n, n_items = 2,
gg = true_params["gg"], gk = true_params["gk"],
kk = true_params["kk"], gamma = true_params["gamma"]
)
tryCatch(
{
fit <- item_lca_fit(sim_data$pre, sim_data$post)
estimates_n[sim, ] <- c(
mean(fit$params["gg", ]),
mean(fit$params["gk", ]),
mean(fit$params["kk", ]),
mean(fit$params["gamma", ])
)
},
error = function(e) NULL
)
}
rmse_by_n[s, ] <- sqrt(colMeans(
(estimates_n - matrix(true_params,
nrow = n_sims_quick,
ncol = 4,
byrow = TRUE
))^2,
na.rm = TRUE
))
}
sample_size_table <- data.frame(
n = sample_sizes,
RMSE_gk = rmse_by_n[, "gk"],
RMSE_ratio = c(NA, rmse_by_n[-nrow(rmse_by_n), "gk"] / rmse_by_n[-1, "gk"])
)
knitr::kable(sample_size_table,
digits = 3, row.names = FALSE,
caption = "RMSE of gk by Sample Size (ratio should be ~sqrt(2) for doubling n)"
)| n | RMSE_gk | RMSE_ratio |
|---|---|---|
| 100 | 0.058 | NA |
| 250 | 0.034 | 1.680 |
| 500 | 0.025 | 1.375 |
| 1000 | 0.017 | 1.499 |
The RMSE ratio between adjacent sample sizes should be approximately \(\sqrt{2} \approx 1.41\) when sample size doubles, confirming the \(1/\sqrt{n}\) efficiency pattern.
When “Don’t Know” responses are available, the model extends to a 9-cell transition matrix with 7 latent classes:
| Class | From | To | Interpretation |
|---|---|---|---|
| gg | guess | guess | Stable ignorance |
| gk | guess | know | Learned |
| gd | guess | DK | Became aware of ignorance |
| kk | know | know | Stable knowledge |
| dg | DK | guess | Stopped confessing, started guessing |
| dk | DK | know | Learned |
| dd | DK | DK | Persistent uncertainty |
Two of the nine conceivable transitions are absent. The model is identified by the assumption that people do not lose knowledge over a short informative process, which sets know→guess and know→DK to zero. Without that restriction there would be 9 parameters against 8 free cell probabilities, and no dataset of any size could separate them.
The learning estimate in the DK model is \(gk + dk\): those who learned the item from guessing, plus those who learned it from confessed ignorance.
sim_dk <- simulate_lca_dk(
n = 500, n_items = 1,
gg = 0.25, gk = 0.15, gd = 0.10,
kk = 0.20, dg = 0.10, dk = 0.10,
dd = 0.10, gamma = 0.25,
seed = 456
)
fit_dk <- item_lca_fit(sim_dk$pre, sim_dk$post)
cat("True learning (gk): 0.15\n")
#> True learning (gk): 0.15
cat(sprintf("Estimated gk: %.3f\n", fit_dk$params["gk", 1]))
#> Estimated gk: 0.132
cat(sprintf("\nTrue total learning (gk + kd): %.2f\n", 0.15 + 0.10))
#>
#> True total learning (gk + kd): 0.25
cat(sprintf("Estimated total: %.3f\n", fit_dk$learning[1]))
#> Estimated total: 0.239The package provides a convenience function for Monte Carlo validation:
Beyond aggregate parameter recovery, we can assess how well the LCA model recovers which specific individuals learned. This is important because the posterior P(learned | data) provides individual-level diagnostic information.
The LCA model leverages the joint transition structure across all items:
For each individual with response vector Y = {(y_pre_j, y_post_j)}, Bayes’ rule gives:
\[P(\text{class} = gk \mid Y) \propto P(\text{class} = gk) \times \prod_j P(y_{\text{pre},j}, y_{\text{post},j} \mid \text{class} = gk, \gamma_j)\]
The package provides posterior_class_probs() to compute
these:
sim <- simulate_lca(
n = 500, n_items = 5, gk = 0.30, gamma = 0.25,
seed = 123, return_classes = TRUE
)
fit <- person_item_lca_fit(sim$pre, sim$post)
posteriors <- posterior_class_probs(fit)
head(posteriors)
#> P_gg P_gk P_kk
#> 1 1.000000e+00 0.0000000000 0.0000000
#> 2 1.172450e-03 0.9988275503 0.0000000
#> 3 8.831195e-07 0.0007523428 0.9992468
#> 4 1.172450e-03 0.9988275503 0.0000000
#> 5 1.172450e-03 0.9988275503 0.0000000
#> 6 1.000000e+00 0.0000000000 0.0000000The key estimand is P(gk | data) = P(learned | data):
The cross-sectional baseline transforms proportion correct at each timepoint and then computes a bounded difference score. It is not a fitted IRT model:
p_learned_cs <- cross_sectional_learning_score(sim$pre, sim$post)
cor_cs <- cor(p_learned_cs, as.numeric(sim$learned))
cat(sprintf("Cross-sectional correlation: %.3f\n", cor_cs))
#> Cross-sectional correlation: 0.758
cat(sprintf("\nLCA advantage: %.3f\n", cor_with_truth - cor_cs))
#>
#> LCA advantage: 0.242We can systematically compare the two approaches:
comparison <- compare_learning_recovery(
n = 500, n_items = 5, gk = 0.30, gamma = 0.25,
n_sims = 50, seed = 456
)
summary_stats <- summarize_learning_comparison(comparison)
knitr::kable(summary_stats,
digits = 3,
caption = "LCA vs. Cross-Sectional Learning Recovery"
)| metric | mean | sd |
|---|---|---|
| cor_lca | 0.999 | 0.002 |
| cor_cs | 0.756 | 0.018 |
| lca_advantage | 0.243 | 0.018 |
As gamma rises, response patterns contain less information about the latent class. The simulation compares how the two recovery measures degrade:
set.seed(789)
gammas <- c(0.15, 0.25, 0.35)
results_by_gamma <- data.frame()
for (g in gammas) {
res <- compare_learning_recovery(
n = 500, n_items = 5, gk = 0.30, gamma = g,
n_sims = 30, seed = NULL
)
res$gamma <- g
results_by_gamma <- rbind(results_by_gamma, res)
}
agg <- aggregate(lca_advantage ~ gamma,
data = results_by_gamma,
FUN = function(x) c(mean = mean(x), se = sd(x) / sqrt(length(x)))
)
agg <- do.call(data.frame, agg)
names(agg) <- c("gamma", "mean", "se")
barplot(agg$mean,
names.arg = agg$gamma,
main = "LCA Advantage by Guessing Rate",
xlab = "Gamma (guessing probability)",
ylab = "Correlation advantage (LCA - CS)",
col = "steelblue"
)The posterior probabilities provide actionable individual-level information:
sim <- simulate_lca(
n = 500, n_items = 5, gk = 0.30, gamma = 0.25,
seed = 101, return_classes = TRUE
)
fit <- person_item_lca_fit(sim$pre, sim$post)
posteriors <- posterior_class_probs(fit)
posteriors$true_class <- sim$true_class
kk_subset <- posteriors[posteriors$true_class == "kk", ]
gk_subset <- posteriors[posteriors$true_class == "gk", ]
gg_subset <- posteriors[posteriors$true_class == "gg", ]
cat("Mean posteriors by true class:\n")
#> Mean posteriors by true class:
cat(sprintf(
" kk class: P_kk=%.3f, P_gk=%.3f, P_gg=%.3f\n",
mean(kk_subset$P_kk), mean(kk_subset$P_gk), mean(kk_subset$P_gg)
))
#> kk class: P_kk=0.999, P_gk=0.001, P_gg=0.000
cat(sprintf(
" gk class: P_kk=%.3f, P_gk=%.3f, P_gg=%.3f\n",
mean(gk_subset$P_kk), mean(gk_subset$P_gk), mean(gk_subset$P_gg)
))
#> gk class: P_kk=0.000, P_gk=0.999, P_gg=0.001
cat(sprintf(
" gg class: P_kk=%.3f, P_gk=%.3f, P_gg=%.3f\n",
mean(gg_subset$P_kk), mean(gg_subset$P_gk), mean(gg_subset$P_gg)
))
#> gg class: P_kk=0.000, P_gk=0.011, P_gg=0.989This vignette demonstrates that the guess package
implementation: 1. Correctly implements the cell probability formulas
from the latent class model 2. Produces unbiased parameter estimates 3.
Has valid standard errors with proper coverage 4. Shows the expected
\(1/\sqrt{n}\) efficiency gains
The latent class model provides a principled way to adjust estimates of learning for guessing bias, recovering the true proportion who learned from pre-post test data.
Cor, K. and Sood, G. (2018). Measuring Learning. Working paper. https://gsood.com/research/papers/k_gains.pdf
Cor, K. and Sood, G. (2016). Adjusting for Guessing. Working paper. https://gsood.com/research/papers/guess.pdf
sessionInfo()
#> R version 4.6.0 (2026-04-24)
#> Platform: aarch64-apple-darwin23
#> Running under: macOS Tahoe 26.5.2
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#>
#> time zone: America/Los_Angeles
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] guess_0.7.0
#>
#> loaded via a namespace (and not attached):
#> [1] cli_3.6.6 knitr_1.51 rlang_1.3.0
#> [4] xfun_0.59 otel_0.2.0 jsonlite_2.0.0
#> [7] backports_1.5.1 future.apply_1.20.2 listenv_1.0.0
#> [10] htmltools_0.5.9 sass_0.4.10 rmarkdown_2.31
#> [13] evaluate_1.0.5 jquerylib_0.1.4 fastmap_1.2.0
#> [16] numDeriv_2016.8-1.1 yaml_2.3.12 lifecycle_1.0.5
#> [19] compiler_4.6.0 codetools_0.2-20 Rsolnp_2.0.1
#> [22] Rcpp_1.1.2 future_1.70.0 truncnorm_1.0-9
#> [25] digest_0.6.39 R6_2.6.1 parallelly_1.48.0
#> [28] parallel_4.6.0 checkmate_2.3.4 bslib_0.11.0
#> [31] tools_4.6.0 globals_0.19.1 cachem_1.1.0