Nothing
# Generate and inspect a deterministic, clearly separated two-level population.
multilpa_fixture <- local({
set.seed(6123)
n_groups <- 80L
group_size <- 16L
group_types <- rep(1:2, each = n_groups / 2)
group_index <- rep(seq_len(n_groups), each = group_size)
profiles <- 1L + as.integer(
runif(length(group_index)) > c(0.9, 0.1)[group_types[group_index]]
)
data <- data.frame(
group = sprintf("school_%03d", group_index),
reading = rnorm(length(profiles), c(-2.3, 2.3)[profiles],
c(0.6, 0.8)[profiles]),
maths = rnorm(length(profiles), c(-1.8, 1.8)[profiles],
c(0.7, 0.9)[profiles])
)
stopifnot(!anyNA(data), !anyDuplicated(data),
all(is.finite(as.matrix(data[c("reading", "maths")]))))
list(data = data, profiles = profiles, group_types = group_types)
})
multilpa_small_data <- data.frame(
group = c("z", "z", "a", "a", "b", "b"),
x = c(-2, -1, 0, 1, 2, 3),
y = c(0, 2, 1, 4, 3, 5)
)
test_that("a multilevel Gaussian population is recovered with valid posteriors", {
data <- multilpa_fixture$data
original_data <- data
fit <- multilpa(data, c("reading", "maths"), "group", 2L, 2L,
n_starts = 4L, max_iter = 600L, seed = 827)
expect_s3_class(fit, "multilpa")
expect_identical(data, original_data)
expect_true(fit$converged)
expect_type(fit$subject_profiles, "integer")
expect_type(fit$group_classes, "integer")
expect_equal(dim(fit$subject_posteriors), c(nrow(data), 2L))
expect_equal(dim(fit$group_posteriors), c(80L, 2L))
expect_equal(rowSums(fit$subject_posteriors), rep(1, nrow(data)),
tolerance = 1e-10, ignore_attr = TRUE)
expect_equal(rowSums(fit$group_posteriors), rep(1, 80L),
tolerance = 1e-10, ignore_attr = TRUE)
expect_true(all(is.finite(fit$subject_posteriors)))
expect_true(all(is.finite(fit$group_posteriors)))
expect_true(all(fit$subject_posteriors >= 0 & fit$subject_posteriors <= 1 + 1e-12))
expect_true(all(fit$group_posteriors >= 0 & fit$group_posteriors <= 1 + 1e-12))
expect_equal(rowSums(fit$profile_probabilities), c(1, 1),
tolerance = 1e-10, ignore_attr = TRUE)
expect_equal(sum(fit$group_probabilities), 1, tolerance = 1e-10)
expect_true(all(fit$variances > 0))
expect_false(fit$boundary)
expect_identical(as.character(fit$group_ids), unique(data$group))
expect_equal(fit$subject_profiles, max.col(fit$subject_posteriors),
ignore_attr = TRUE)
expect_equal(fit$group_classes, max.col(fit$group_posteriors),
ignore_attr = TRUE)
# Resolve arbitrary mixture labels before checking statistical recovery.
profile_order <- order(fit$means[, "reading"])
fitted_means <- fit$means[profile_order, , drop = FALSE]
fitted_variances <- fit$variances[profile_order, , drop = FALSE]
expect_equal(unname(fitted_means), matrix(c(-2.3, -1.8, 2.3, 1.8),
nrow = 2L, byrow = TRUE),
tolerance = 0.15)
expect_equal(unname(fitted_variances), matrix(c(0.36, 0.49, 0.64, 0.81),
nrow = 2L, byrow = TRUE),
tolerance = 0.15)
sorted_probabilities <- fit$profile_probabilities[, profile_order, drop = FALSE]
group_order <- order(sorted_probabilities[, 1L], decreasing = TRUE)
expect_equal(unname(sorted_probabilities[group_order, , drop = FALSE]),
matrix(c(0.9, 0.1, 0.1, 0.9), nrow = 2L, byrow = TRUE),
tolerance = 0.1)
expect_gt(mean(match(fit$subject_profiles, profile_order) ==
multilpa_fixture$profiles), 0.97)
expect_gt(mean(match(fit$group_classes, group_order) ==
multilpa_fixture$group_types), 0.9)
expect_length(fit$log_likelihood_history, fit$iterations + 1L)
expect_true(all(diff(fit$log_likelihood_history) >= -1e-7))
expect_equal(tail(fit$log_likelihood_history, 1L), fit$log_likelihood,
ignore_attr = TRUE)
expect_equal(fit$n_parameters, 11L)
expect_equal(fit$aic, -2 * fit$log_likelihood + 2 * fit$n_parameters)
expect_equal(fit$bic, -2 * fit$log_likelihood + log(80) * fit$n_parameters)
expect_equal(fit$bic_individual,
-2 * fit$log_likelihood + log(nrow(data)) * fit$n_parameters)
expect_s3_class(fit$starts, "data.frame")
expect_equal(nrow(fit$starts), 4L)
expect_true(all(c("start", "log_likelihood", "converged", "iterations",
"error", "boundary") %in% names(fit$starts)))
expect_equal(fit$log_likelihood, max(fit$starts$log_likelihood, na.rm = TRUE))
})
test_that("one profile gives the analytic maximum likelihood solution", {
fit <- multilpa(multilpa_small_data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 81)
expected_means <- vapply(multilpa_small_data[c("x", "y")], mean, numeric(1))
expected_variances <- vapply(multilpa_small_data[c("x", "y")], function(x) {
mean((x - mean(x))^2)
}, numeric(1))
expected_log_likelihood <- sum(dnorm(multilpa_small_data$x, expected_means["x"],
sqrt(expected_variances["x"]), log = TRUE)) +
sum(dnorm(multilpa_small_data$y, expected_means["y"],
sqrt(expected_variances["y"]), log = TRUE))
expect_equal(as.numeric(fit$means), unname(expected_means), tolerance = 1e-10)
expect_equal(as.numeric(fit$variances), unname(expected_variances),
tolerance = 1e-10)
expect_equal(fit$log_likelihood, expected_log_likelihood, tolerance = 1e-10)
expect_equal(unname(fit$subject_posteriors), matrix(1, nrow = 6L, ncol = 1L))
expect_equal(unname(fit$group_posteriors), matrix(1, nrow = 3L, ncol = 1L))
expect_equal(fit$n_parameters, 4L)
expect_equal(as.numeric(logLik(fit)), expected_log_likelihood)
expect_equal(attr(logLik(fit), "df"), 4L)
expect_equal(attr(logLik(fit), "nobs"), 3L)
expect_equal(nobs(fit), 3L)
expect_equal(AIC(fit), fit$aic)
expect_equal(BIC(fit), fit$bic)
expect_equal(AIC(fit, k = log(3)), fit$bic)
expect_output(printed_fit <- withVisible(print(fit)), "6 individuals in 3 groups")
expect_false(printed_fit$visible)
expect_identical(printed_fit$value, fit)
summary_fit <- summary(fit)
expect_s3_class(summary_fit, "summary_multilpa")
# A field the fit does not carry must take its documented default, not leave
# an unnamed hole in the summary.
expect_false(anyNA(names(summary_fit)))
expect_equal(as.data.frame(summary_fit)$mean, as.vector(t(fit$means)))
counts <- get_results(summary_fit, "counts")
expect_equal(subset(counts, level == "individuals")$effective_count,
colSums(fit$subject_posteriors), ignore_attr = TRUE)
expect_equal(subset(counts, level == "groups")$effective_count,
colSums(fit$group_posteriors), ignore_attr = TRUE)
expect_output(printed_summary <- withVisible(print(summary_fit)),
"-- counts", fixed = TRUE)
expect_false(printed_summary$visible)
expect_identical(printed_summary$value, summary_fit)
expect_error(print(summary_fit, digits = 0))
})
test_that("equal variances are shared across profiles and counted correctly", {
fit <- multilpa(multilpa_fixture$data, c("reading", "maths"), "group",
2L, 2L, variance_model = "equal", n_starts = 1, seed = 712)
expect_equal(fit$variances[1L, ], fit$variances[2L, ], ignore_attr = TRUE)
expect_equal(fit$n_parameters, 9L)
expect_true(all(diff(fit$log_likelihood_history) >= -1e-7))
})
test_that("explicit starts give the independently calculated initial likelihood", {
start <- list(
means = matrix(c(-1, 1, 2, 4), nrow = 2L, byrow = TRUE),
variances = matrix(c(1.5, 2, 2.5, 1), nrow = 2L, byrow = TRUE),
profile_probabilities = matrix(c(0.8, 0.2, 0.3, 0.7),
nrow = 2L, byrow = TRUE),
group_probabilities = c(0.4, 0.6)
)
# Small, moderate densities allow direct probability arithmetic independent
# of the fitter's log-sum-exp implementation.
density <- cbind(
dnorm(multilpa_small_data$x, -1, sqrt(1.5)) *
dnorm(multilpa_small_data$y, 1, sqrt(2)),
dnorm(multilpa_small_data$x, 2, sqrt(2.5)) *
dnorm(multilpa_small_data$y, 4, 1)
)
conditional_density <- density %*% t(start$profile_probabilities)
likelihood_by_group_type <- t(vapply(unique(multilpa_small_data$group),
function(group) {
apply(conditional_density[multilpa_small_data$group == group, , drop = FALSE],
2L, prod)
}, numeric(2)))
expected_log_likelihood <- sum(log(as.numeric(
likelihood_by_group_type %*% start$group_probabilities
)))
expect_warning(
fit <- multilpa(multilpa_small_data, c("x", "y"), "group", 2L, 2L,
n_starts = 1L, max_iter = 1L, start = start, seed = 92),
"converg|iteration"
)
expect_equal(fit$log_likelihood_history[1L], expected_log_likelihood,
tolerance = 1e-10)
expect_false(fit$converged)
expect_equal(fit$iterations, 1L)
expect_length(fit$log_likelihood_history, 2L)
expect_gte(fit$log_likelihood, expected_log_likelihood - 1e-10)
})
test_that("group identifiers preserve first occurrence without unused levels", {
data <- multilpa_small_data[c(5L, 2L, 3L, 6L, 1L, 4L), , drop = FALSE]
data$group <- factor(data$group, levels = c("unused", "a", "b", "z"))
fit <- multilpa(data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 42)
expect_identical(as.character(fit$group_ids), c("b", "z", "a"))
expect_equal(nrow(fit$group_posteriors), 3L)
expect_equal(length(fit$subject_profiles), nrow(data))
})
test_that("nearby numeric group identifiers are not merged during display conversion", {
data <- multilpa_small_data
data$group <- rep(c(1 + 1e-15, 1), each = 3L)
stopifnot(length(unique(data$group)) == 2L)
fit <- multilpa(data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 82)
expect_equal(fit$n_groups, 2L)
expect_identical(fit$group_index, rep(1:2, each = 3L))
expect_equal(unname(fit$group_sizes), c(3L, 3L))
expect_length(fit$group_ids, 2L)
expect_false(anyDuplicated(fit$group_ids) > 0L)
expect_equal(dim(fit$group_posteriors), c(2L, 1L))
})
test_that("a single group class permits singleton groups and one indicator", {
data <- data.frame(group = seq_len(12L), x = c(seq(-3, -2, length.out = 6L),
seq(2, 3, length.out = 6L)))
fit <- multilpa(data, "x", "group", 2L, 1L,
n_starts = 1, seed = 62)
expect_equal(dim(fit$means), c(2L, 1L))
expect_equal(dim(fit$variances), c(2L, 1L))
expect_equal(dim(fit$group_posteriors), c(12L, 1L))
expect_equal(fit$n_parameters, 5L)
expect_equal(sort(as.numeric(fit$means)), c(-2.5, 2.5), tolerance = 1e-5)
})
test_that("a two-observation one-profile fit does not lose matrix dimensions", {
data <- data.frame(group = c("a", "a"), score = c(2, 4))
fit <- multilpa(data, "score", "group", 1L, 1L,
n_starts = 1L, seed = 6)
expect_equal(dim(fit$means), c(1L, 1L))
expect_equal(dim(fit$variances), c(1L, 1L))
expect_equal(as.numeric(fit$means), 3)
expect_equal(as.numeric(fit$variances), 1)
expect_equal(fit$log_likelihood, sum(dnorm(c(2, 4), 3, 1, log = TRUE)))
})
test_that("large indicator offsets preserve variances and likelihood", {
original_fit <- multilpa(multilpa_small_data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 62)
shifted_data <- transform(multilpa_small_data, x = x + 1e10, y = y - 1e10)
shifted_fit <- multilpa(shifted_data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 62)
expect_equal(unname(shifted_fit$means - original_fit$means),
matrix(c(1e10, -1e10), nrow = 1L))
expect_equal(shifted_fit$variances, original_fit$variances, tolerance = 1e-10)
expect_equal(shifted_fit$log_likelihood, original_fit$log_likelihood,
tolerance = 1e-10)
})
test_that("seeded fitting is repeatable and preserves an existing RNG state", {
set.seed(198)
state_before <- .Random.seed
fit_one <- multilpa(multilpa_fixture$data, c("reading", "maths"), "group", 2L, 2L,
n_starts = 2L, seed = 612)
expect_identical(.Random.seed, state_before)
fit_two <- multilpa(multilpa_fixture$data, c("reading", "maths"), "group", 2L, 2L,
n_starts = 2L, seed = 612)
expect_identical(fit_one$means, fit_two$means)
expect_identical(fit_one$variances, fit_two$variances)
expect_identical(fit_one$log_likelihood_history, fit_two$log_likelihood_history)
expect_identical(fit_one$starts, fit_two$starts)
expect_identical(.Random.seed, state_before)
})
test_that("permuting input rows permutes posteriors without changing the fit", {
data <- multilpa_fixture$data
start <- list(
means = matrix(c(-2.3, -1.8, 2.3, 1.8), nrow = 2L, byrow = TRUE),
variances = matrix(c(0.36, 0.49, 0.64, 0.81), nrow = 2L, byrow = TRUE),
profile_probabilities = matrix(c(0.9, 0.1, 0.1, 0.9),
nrow = 2L, byrow = TRUE),
group_probabilities = c(0.5, 0.5)
)
fit <- multilpa(data, c("reading", "maths"), "group", 2L, 2L,
n_starts = 1L, start = start, seed = 81)
row_order <- rev(seq_len(nrow(data)))
reordered_fit <- multilpa(data[row_order, , drop = FALSE],
c("reading", "maths"), "group", 2L, 2L,
n_starts = 1L, start = start, seed = 81)
expect_equal(reordered_fit$log_likelihood, fit$log_likelihood, tolerance = 1e-9)
expect_equal(reordered_fit$means, fit$means, tolerance = 1e-8)
expect_equal(reordered_fit$subject_posteriors,
fit$subject_posteriors[row_order, , drop = FALSE],
tolerance = 1e-8, ignore_attr = TRUE)
group_order <- match(reordered_fit$group_ids, fit$group_ids)
expect_equal(reordered_fit$group_posteriors,
fit$group_posteriors[group_order, , drop = FALSE],
tolerance = 1e-8, ignore_attr = TRUE)
expect_identical(as.character(reordered_fit$group_ids),
unique(data$group[row_order]))
})
test_that("seeded fitting restores the absence of an RNG state", {
previous_state <- if (exists(".Random.seed", envir = .GlobalEnv,
inherits = FALSE)) .GlobalEnv$.Random.seed else NULL
on.exit({
if (!is.null(previous_state)) {
assign(".Random.seed", previous_state, envir = .GlobalEnv)
}
}, add = TRUE)
if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
rm(".Random.seed", envir = .GlobalEnv)
}
fit <- multilpa(multilpa_small_data, c("x", "y"), "group", 1L, 1L,
n_starts = 1L, seed = 27)
expect_false(exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE))
expect_true(is.finite(fit$log_likelihood))
})
test_that("seed restoration also runs when fitting fails", {
set.seed(481)
state_before <- .Random.seed
data <- data.frame(group = c("a", "a"), score = c(-1e200, 1e200))
expect_error(multilpa(data, "score", "group", 1L, 1L,
n_starts = 1L, seed = 62), "overflow|rescale")
expect_identical(.Random.seed, state_before)
})
test_that("invalid data and unidentifiable specifications fail explicitly", {
args <- list(data = multilpa_small_data, vars = c("x", "y"),
group = "group", n_profiles = 2L, n_group_classes = 2L,
n_starts = 1L)
invalid_data <- list(
transform(multilpa_small_data, x = c(NA, -1, 0, 1, 2, 3)),
transform(multilpa_small_data, x = c(Inf, -1, 0, 1, 2, 3)),
transform(multilpa_small_data, x = c(NaN, -1, 0, 1, 2, 3)),
transform(multilpa_small_data, x = as.character(x)),
transform(multilpa_small_data, x = 1),
transform(multilpa_small_data, group = c(NA, "z", "a", "a", "b", "b")),
multilpa_small_data[FALSE, , drop = FALSE]
)
invisible(lapply(invalid_data, function(data) {
invalid_args <- args
invalid_args$data <- data
expect_error(do.call(multilpa, invalid_args))
}))
invalid_specs <- list(
list(vars = "missing"), list(vars = character()),
list(vars = c("x", "x")), list(group = "missing"),
list(group = c("group", "x")), list(vars = c("x", "group")),
list(n_profiles = 7L), list(n_group_classes = 4L),
list(n_profiles = 1L, n_group_classes = 2L),
list(data = transform(multilpa_small_data, group = seq_len(nrow(multilpa_small_data))))
)
invisible(lapply(invalid_specs, function(spec) {
expect_error(do.call(multilpa, utils::modifyList(args, spec)))
}))
})
test_that("invalid numeric controls and variance models are rejected", {
args <- list(data = multilpa_small_data, vars = c("x", "y"),
group = "group", n_profiles = 2L, n_group_classes = 2L,
n_starts = 1L)
invalid_specs <- list(
list(n_profiles = 0L), list(n_profiles = 1.5), list(n_profiles = NA),
list(n_group_classes = 0L), list(n_group_classes = 1.5),
list(n_starts = 0L), list(n_starts = 1.5), list(n_starts = Inf),
list(max_iter = -1L), list(max_iter = 1.5), list(max_iter = NA),
list(tol = 0), list(tol = -1), list(tol = Inf),
list(min_variance = 0), list(min_variance = -1), list(min_variance = NA),
list(seed = NA), list(seed = c(1, 2)), list(variance_model = "full")
)
invisible(lapply(invalid_specs, function(spec) {
expect_error(do.call(multilpa, utils::modifyList(args, spec)))
}))
})
test_that("malformed explicit starting values are rejected", {
start <- list(
means = matrix(c(-1, 1, 2, 4), nrow = 2L, byrow = TRUE),
variances = matrix(1, nrow = 2L, ncol = 2L),
profile_probabilities = matrix(c(0.8, 0.2, 0.3, 0.7),
nrow = 2L, byrow = TRUE),
group_probabilities = c(0.4, 0.6)
)
invalid_starts <- list(
start[c("means", "variances")],
utils::modifyList(start, list(means = matrix(1, nrow = 1L, ncol = 2L))),
utils::modifyList(start, list(means = matrix(NA_real_, nrow = 2L, ncol = 2L))),
utils::modifyList(start, list(variances = matrix(-1, nrow = 2L, ncol = 2L))),
utils::modifyList(start, list(variances = matrix(1e-12, nrow = 2L, ncol = 2L))),
utils::modifyList(start, list(profile_probabilities = matrix(1, 2L, 2L))),
utils::modifyList(start, list(profile_probabilities = matrix(c(-0.1, 1.1, 0.3, 0.7),
2L, byrow = TRUE))),
utils::modifyList(start, list(profile_probabilities = matrix(c(0, 1, 0.3, 0.7),
2L, byrow = TRUE))),
utils::modifyList(start, list(group_probabilities = c(0.4, 0.5))),
utils::modifyList(start, list(group_probabilities = c(-0.1, 1.1))),
utils::modifyList(start, list(group_probabilities = c(0, 1))),
utils::modifyList(start, list(group_probabilities = c(NA, 1)))
)
invisible(lapply(invalid_starts, function(invalid_start) {
expect_error(multilpa(multilpa_small_data, c("x", "y"), "group", 2L, 2L,
n_starts = 1L, start = invalid_start))
}))
start$variances[1L, 1L] <- 2
expect_error(multilpa(multilpa_small_data, c("x", "y"), "group", 2L, 2L,
variance_model = "equal", n_starts = 1L, start = start))
})
test_that("a binding variance floor is reported and uses constrained ML", {
data <- data.frame(group = c("a", "a", "b", "b"),
x = c(-0.001, 0.001, -0.002, 0.002))
expect_warning(
fit <- multilpa(data, "x", "group", 1L, 1L, n_starts = 1L,
min_variance = 0.01, seed = 17),
"variance|boundary|floor"
)
expect_true(fit$boundary)
expect_equal(as.numeric(fit$variances), 0.01)
expect_equal(fit$log_likelihood, sum(dnorm(data$x, 0, 0.1, log = TRUE)))
expect_true(all(diff(fit$log_likelihood_history) >= -1e-7))
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.