R/inference.R

Defines functions .multilpa_tidy_labels .multilpa_inference_matrix .multilpa_number_single_level .multilpa_check_regularity confint.multilpa vcov.multilpa coef.multilpa .multilpa_probability_floor .multilpa_response_bound parameter_inference.multilpa parameter_inference .multilpa_inference_jacobian .multilpa_full_measurement_score .multilpa_score .multilpa_response_scores .multilpa_decode .multilpa_restore_held .multilpa_free_index .multilpa_coordinate_widths .multilpa_covariance_coordinates .multilpa_response_width .multilpa_response_decode .multilpa_response_coordinates .multilpa_coefficients_raw .multilpa_coefficients .multilpa_coefficient_labels .multilpa_parameter_names

Documented in coef.multilpa confint.multilpa parameter_inference parameter_inference.multilpa vcov.multilpa

#' Serialise a tidy parameter decomposition into one name per parameter
#'
#' Every class in this package decomposes a parameter the same way --
#' `level`, `parameter`, `outcome`, `term` -- and those four columns are what
#' [parameter_inference()] reports. A `coef()` name is that decomposition
#' written on one line, dot separated and in that order, so a name can always be
#' read back into the columns and two fits of different families spell the same
#' concept the same way. `level`, `parameter` and `outcome` never contain a dot,
#' so the first three separators are unambiguous and everything after the third
#' is the term. A term that does not apply -- a group-class probability has no
#' term -- is left off rather than spelled `NA`.
#'
#' @param labels A data frame with `level`, `parameter`, `outcome` and `term`.
#' @return A character vector, one name per row of `labels`.
#' @noRd
.multilpa_parameter_names <- function(labels) {
  stopifnot(
    "`labels` must carry level, parameter, outcome and term" =
      is.data.frame(labels) &&
      all(c("level", "parameter", "outcome", "term") %in% names(labels))
  )
  stem <- paste(labels$level, labels$parameter, labels$outcome, sep = ".")
  ifelse(is.na(labels$term), stem, paste(stem, labels$term, sep = "."))
}

#' The tidy decomposition of every coefficient of a fitted mixture
#' @param object A fitted `multilpa` model.
#' @param scale Natural estimates or unconstrained fitting coordinates.
#' @return A data frame with `level`, `outcome`, `term` and `parameter`, one row
#'   per coefficient, in the order `.multilpa_coefficients()` returns them.
#' @noRd
.multilpa_coefficient_labels <- function(object, scale) {
  .multilpa_tidy_labels(names(.multilpa_coefficients_raw(object, scale)), object)
}

#' Parameterize a fitted mixture
#' @param object A fitted `multilpa` model.
#' @param scale Natural estimates or unconstrained fitting coordinates.
#' @return A named numeric parameter vector, named as
#'   `.multilpa_parameter_names()` spells them.
#' @noRd
.multilpa_coefficients <- function(object, scale) {
  values <- .multilpa_coefficients_raw(object, scale)
  stats::setNames(unname(values),
                  .multilpa_parameter_names(.multilpa_tidy_labels(names(values), object)))
}

#' Parameterize a fitted mixture, in the internal generation grammar
#'
#' The generated `kind[index,...]` labels are an implementation detail: they are
#' compact to build alongside the values and they are parsed straight back into
#' tidy columns by `.multilpa_tidy_labels()`. Nothing user facing sees them;
#' `.multilpa_coefficients()` renames them before they leave the package.
#'
#' @param object A fitted `multilpa` model.
#' @param scale Natural estimates or unconstrained fitting coordinates.
#' @return A named numeric parameter vector.
#' @noRd
.multilpa_coefficients_raw <- function(object, scale) {
  stopifnot(inherits(object, "multilpa"), scale %in% c("natural", "unconstrained"))
  n_profiles <- object$n_profiles
  n_types <- object$n_group_classes
  ## Only the Gaussian indicators carry means and variances; a mixed or
  ## all-categorical fit has fewer of these than it has indicators.
  continuous <- .multilpa_continuous_names(object)
  n_indicators <- length(continuous)
  mean_names <- as.vector(t(outer(seq_len(n_profiles), continuous,
    function(profile, indicator) sprintf("mean[%s,%s]", profile, indicator))))
  shared <- .multilpa_spread_shared(object)
  variance_names <- if (shared) {
    sprintf("variance[shared,%s]", continuous)
  } else sub("^mean", "variance", mean_names)
  variance_values <- if (shared) object$variances[1L, ] else
    as.vector(t(object$variances))
  profile_indices <- if (scale == "natural") seq_len(n_profiles) else seq_len(n_profiles - 1L)
  group_indices <- if (scale == "natural") seq_len(n_types) else seq_len(n_types - 1L)
  profile_names <- as.vector(t(outer(seq_len(n_types), profile_indices,
    function(id, profile) sprintf("profile_probability[%s,%s]", id, profile))))
  group_names <- sprintf("group_probability[%s]", group_indices)
  profile_values <- as.vector(t(object$profile_probabilities[, profile_indices, drop = FALSE]))
  group_values <- object$group_probabilities[group_indices]
  if (scale == "unconstrained") {
    variance_values <- log(variance_values)
    variance_names <- sub("^variance", "log_variance", variance_names)
    profile_values <- as.vector(t(log(object$profile_probabilities[, profile_indices, drop = FALSE]) -
      log(object$profile_probabilities[, n_profiles])))
    group_values <- log(group_values) - log(object$group_probabilities[n_types])
    profile_names <- sub("^profile_probability", "profile_logit", profile_names)
    group_names <- sub("^group_probability", "group_logit", group_names)
  }
  if (identical(object$covariance_model, "full")) {
    covariance_coordinates <- .multilpa_covariance_coordinates(object, scale)
    variance_values <- unname(covariance_coordinates)
    variance_names <- names(covariance_coordinates)
  }
  ## A constrained structure is estimated in its own chart: log volumes, log
  ## shapes and orientations, one coordinate per parameter it has.
  if (scale == "unconstrained" && .multilpa_uses_chart(object)) {
    chart <- .multilpa_chart_encode(object)$values
    variance_values <- unname(chart)
    variance_names <- names(chart)
  }
  response <- .multilpa_response_coordinates(object, scale)
  extra <- .latents_extra_coordinates(object, object$extra_data, n_profiles, scale)
  stats::setNames(c(as.vector(t(object$means)), variance_values,
                   unname(response), unname(extra), profile_values, group_values),
                 c(mean_names, variance_names, names(response), names(extra),
                   profile_names, group_names))
}

#' Encode categorical response probabilities
#'
#' Natural coordinates are the probabilities themselves; unconstrained
#' coordinates are multinomial logits against each indicator's last category,
#' which is the same simplex device the mixing probabilities already use.
#'
#' @param object A fitted `multilpa` model.
#' @param scale `"natural"` or `"unconstrained"`.
#' @return A named numeric vector, empty when no indicator is categorical.
#' @noRd
.multilpa_response_coordinates <- function(object, scale) {
  blocks <- object$response_probabilities
  if (is.null(blocks) || length(blocks) == 0L) return(stats::setNames(numeric(0), character(0)))
  prefix <- if (scale == "natural") "response" else "response_logit"
  unlist(lapply(names(blocks), function(indicator) {
    block <- blocks[[indicator]]
    categories <- colnames(block) %||% as.character(seq_len(ncol(block)))
    keep <- if (scale == "natural") seq_len(ncol(block)) else seq_len(ncol(block) - 1L)
    values <- if (scale == "natural") block[, keep, drop = FALSE] else
      log(block[, keep, drop = FALSE]) - log(block[, ncol(block)])
    labels <- as.vector(t(outer(seq_len(nrow(block)), categories[keep],
      function(profile, category) sprintf("%s[%s,%s,%s]", prefix, profile,
                                          indicator, category))))
    stats::setNames(as.vector(t(values)), labels)
  }), use.names = TRUE)
}

#' Rebuild response probabilities from multinomial logits
#' @param theta Unconstrained values for the categorical block, in encode order.
#' @param object The fit supplying the shapes and names.
#' @return A named list of profiles-by-categories probability matrices.
#' @noRd
.multilpa_response_decode <- function(theta, object) {
  blocks <- object$response_probabilities
  at <- 0L
  result <- lapply(blocks, function(block) {
    n_free <- nrow(block) * (ncol(block) - 1L)
    logits <- cbind(matrix(theta[at + seq_len(n_free)], nrow(block),
                           ncol(block) - 1L, byrow = TRUE), 0)
    at <<- at + n_free
    probabilities <- exp(sweep(logits, 1L, .multilpa_log_sum_exp(logits), "-"))
    dimnames(probabilities) <- dimnames(block)
    probabilities
  })
  names(result) <- names(blocks)
  result
}

#' How many free parameters the categorical block contributes
#' @param object A fitted `multilpa` model.
#' @return A single integer, zero when no indicator is categorical.
#' @noRd
.multilpa_response_width <- function(object) {
  blocks <- object$response_probabilities
  if (is.null(blocks) || length(blocks) == 0L) return(0L)
  sum(vapply(blocks, function(block) nrow(block) * (ncol(block) - 1L), numeric(1)))
}

#' Encode unrestricted Gaussian covariance matrices
#' @param object A fitted full-covariance model.
#' @param scale Natural covariance or log-Cholesky coordinates.
#' @return A named vector of nonredundant covariance parameters.
#' @noRd
.multilpa_covariance_coordinates <- function(object, scale) {
  stopifnot(inherits(object, "multilpa"), identical(object$covariance_model, "full"),
            scale %in% c("natural", "unconstrained"))
  dimension <- length(.multilpa_continuous_names(object))
  if (dimension == 0L) return(stats::setNames(numeric(0), character(0)))
  shared <- .multilpa_spread_shared(object)
  profiles <- if (shared) 1L else seq_len(object$n_profiles)
  lower <- lower.tri(matrix(0, dimension, dimension), diag = TRUE)
  positions <- which(lower, arr.ind = TRUE)
  unlist(lapply(profiles, function(profile) {
    stopifnot(is.numeric(profile), length(profile) == 1L)
    covariance <- matrix(object$covariances[, , profile], dimension, dimension)
    values <- if (scale == "natural") covariance else t(chol(covariance))
    if (scale == "unconstrained") diag(values) <- log(diag(values))
    prefix <- if (scale == "natural") "covariance" else "cholesky"
    labels <- sprintf("%s[%s,%s,%s]", prefix,
      if (shared) "shared" else profile,
      .multilpa_continuous_names(object)[positions[, 1L]],
      .multilpa_continuous_names(object)[positions[, 2L]])
    if (scale == "unconstrained") {
      labels[positions[, 1L] == positions[, 2L]] <- sub("^cholesky", "log_cholesky",
        labels[positions[, 1L] == positions[, 2L]])
    }
    stats::setNames(values[lower], labels)
  }), use.names = TRUE)
}

#' Widths of the coefficient blocks, in the order they are encoded
#'
#' `.multilpa_coefficients_raw()` lays a fit out as means, spread, categorical
#' responses, profile mixing and group mixing, in that order, on either scale.
#' Counting those widths arithmetically -- rather than encoding the fit and
#' measuring the result -- is what lets the free-coordinate map below be built
#' without a Cholesky factorisation on every likelihood evaluation.
#'
#' @param object A fitted `multilpa` model.
#' @param scale `"natural"` or `"unconstrained"`.
#' @return A named numeric vector with `means`, `variances`,
#'   `response_probabilities`, `profile` and `group`, whose sum is the length of
#'   `.multilpa_coefficients()` on that scale.
#' @noRd
.multilpa_coordinate_widths <- function(object, scale) {
  stopifnot("`object` must be a fitted multilpa model" = inherits(object, "multilpa"),
            "`scale` must be natural or unconstrained" =
              length(scale) == 1L && scale %in% c("natural", "unconstrained"))
  n_profiles <- object$n_profiles
  n_types <- object$n_group_classes
  n_continuous <- length(.multilpa_continuous_names(object))
  spread <- if (identical(object$covariance_model, "full")) {
    n_continuous * (n_continuous + 1L) / 2L
  } else n_continuous
  blocks <- object$response_probabilities %||% list()
  spread_width <- if (scale == "unconstrained" && .multilpa_uses_chart(object)) {
    .multilpa_structure_parameters(object$covariance_structure, n_profiles,
                                   n_continuous)
  } else spread * if (.multilpa_spread_shared(object)) 1L else n_profiles
  c(means = n_profiles * n_continuous,
    variances = spread_width,
    response_probabilities = if (scale == "natural") {
      sum(vapply(blocks, length, numeric(1)))
    } else .multilpa_response_width(object),
    extra = .latents_extra_width(object$extra_data, n_profiles),
    profile = n_types * if (scale == "natural") n_profiles else n_profiles - 1L,
    group = if (scale == "natural") n_types else n_types - 1L)
}

#' The coordinates a fit actually estimates
#'
#' A `fixed` fit holds whole measurement blocks at the values `start` supplied,
#' so those coordinates are constants of the likelihood, not parameters. They
#' must still enter the likelihood -- the model is conditional on them, not
#' without them -- but they carry no score, no row of the information matrix and
#' no row of the inference table. Because `fixed` names whole blocks and the
#' blocks are contiguous in the encoding, the free set is simply the complement
#' of the held ranges; there is no partially-held block whose free remainder
#' would need a separate identification argument.
#'
#' @param object A fitted `multilpa` model.
#' @param scale `"natural"` or `"unconstrained"`.
#' @return An integer vector of positions into `.multilpa_coefficients()` on that
#'   scale, ascending, naming the coordinates this fit estimated.
#' @noRd
.multilpa_free_index <- function(object, scale) {
  widths <- .multilpa_coordinate_widths(object, scale)
  total <- as.integer(sum(widths))
  held <- object$fixed %||% character()
  if (length(held) == 0L) return(seq_len(total))
  measurement <- c("means", "variances", "response_probabilities")
  unknown <- setdiff(held, measurement)
  if (length(unknown) > 0L) {
    stop(errorCondition(sprintf(
      "This fit holds %s, which is not a measurement block inference can restore.",
      paste(sprintf("`%s`", unknown), collapse = " or ")),
      class = "latents_bad_fixed", call = NULL))
  }
  offsets <- cumsum(c(0, widths[measurement]))
  frozen <- unlist(lapply(seq_along(measurement), function(position) {
    if (measurement[[position]] %in% held) {
      offsets[[position]] + seq_len(widths[[measurement[[position]]]])
    } else integer(0)
  }), use.names = FALSE)
  setdiff(seq_len(total), as.integer(frozen))
}

#' Restore the held coordinates around a free parameter vector
#'
#' The returned closure scatters free values into a template taken from the fit
#' itself, so a held block enters every likelihood, score and Hessian evaluation
#' at exactly the value it was held at.
#'
#' @param template Full-length unconstrained coordinates of the fit whose held
#'   values are to be restored; the centred fit, when the likelihood is
#'   evaluated on centred indicators.
#' @param free Positions of the free coordinates within `template`.
#' @return A function of the free values returning the full coordinate vector.
#' @noRd
.multilpa_restore_held <- function(template, free) {
  stopifnot("`template` must be numeric" = is.numeric(template),
            "`free` must index `template`" = is.numeric(free) &&
              length(free) <= length(template) &&
              (length(free) == 0L || (min(free) >= 1L && max(free) <= length(template))))
  template <- unname(template)
  function(values) {
    stopifnot("free values must match the free coordinate count" =
                is.numeric(values) && length(values) == length(free))
    full <- template
    full[free] <- values
    full
  }
}

#' Decode unconstrained mixture coordinates
#' @param theta Parameter vector with log variances and baseline-category
#'   logits, carrying every coordinate of the model including any held block.
#' @param object A fitted model defining the parameter dimensions.
#' @return The four parameter blocks used by the expectation step.
#' @noRd
.multilpa_decode <- function(theta, object) {
  stopifnot(is.numeric(theta), inherits(object, "multilpa"),
            "`theta` must carry one value per model coordinate, held blocks included" =
              length(theta) ==
              sum(.multilpa_coordinate_widths(object, "unconstrained")))
  theta <- unname(theta)
  n_profiles <- object$n_profiles
  n_types <- object$n_group_classes
  n_indicators <- length(.multilpa_continuous_names(object))
  n_means <- n_profiles * n_indicators
  n_covariance <- if (identical(object$covariance_model, "full"))
    n_indicators * (n_indicators + 1L) / 2L else n_indicators
  n_variances <- .multilpa_coordinate_widths(object, "unconstrained")[["variances"]]
  n_logits <- n_types * (n_profiles - 1L)
  means <- matrix(theta[seq_len(n_means)], n_profiles, byrow = TRUE)
  covariances <- NULL
  if (.multilpa_uses_chart(object)) {
    spread <- .multilpa_chart_decode(theta[n_means + seq_len(n_variances)], object)
    variances <- spread$variances
    covariances <- spread$covariances
  } else if (identical(object$covariance_model, "full") && n_indicators > 0L) {
    lower <- lower.tri(matrix(0, n_indicators, n_indicators), diag = TRUE)
    covariance_list <- lapply(seq_len(n_profiles), function(profile) {
      stopifnot(is.numeric(profile), length(profile) == 1L)
      block <- if (object$variance_model == "equal") 1L else profile
      factor <- matrix(0, n_indicators, n_indicators)
      factor[lower] <- theta[n_means + (block - 1L) * n_covariance + seq_len(n_covariance)]
      diag(factor) <- exp(diag(factor))
      tcrossprod(factor)
    })
    covariances <- array(unlist(covariance_list, use.names = FALSE),
      c(n_indicators, n_indicators, n_profiles))
    variances <- t(matrix(vapply(covariance_list, diag, numeric(n_indicators)),
      n_indicators, n_profiles))
  } else {
    variances <- matrix(exp(theta[n_means + seq_len(n_variances)]),
      n_profiles, n_indicators, byrow = TRUE)
  }
  n_response <- .multilpa_response_width(object)
  response_probabilities <- if (n_response == 0L) NULL else
    .multilpa_response_decode(theta[n_means + n_variances + seq_len(n_response)],
                              object)
  n_extra <- .latents_extra_width(object$extra_data, n_profiles)
  extra_blocks <- if (n_extra == 0L) NULL else
    .latents_extra_decode(theta[n_means + n_variances + n_response + seq_len(n_extra)],
                          object$extra_data, n_profiles)
  offset <- n_means + n_variances + n_response + n_extra
  profile_logits <- cbind(matrix(theta[offset + seq_len(n_logits)],
    n_types, n_profiles - 1L, byrow = TRUE), 0)
  profile_probabilities <- exp(sweep(profile_logits, 1L,
    .multilpa_log_sum_exp(profile_logits), "-"))
  group_logits <- c(theta[offset + n_logits + seq_len(n_types - 1L)], 0)
  group_probabilities <- exp(group_logits - max(group_logits))
  parameters <- list(means = means, variances = variances,
    profile_probabilities = profile_probabilities,
    group_probabilities = group_probabilities / sum(group_probabilities))
  if (!is.null(covariances)) parameters$covariances <- covariances
  if (!is.null(response_probabilities)) {
    parameters$response_probabilities <- response_probabilities
  }
  if (!is.null(extra_blocks)) parameters[names(extra_blocks)] <- extra_blocks
  parameters
}

#' Score contributions of the categorical response probabilities
#'
#' For one indicator the complete-data score of a multinomial logit is the
#' posterior-weighted difference between the category actually observed and the
#' probability the model gives it, exactly as for the mixing weights. Missing
#' codes contribute nothing.
#'
#' @param codes Integer code matrix, one column per categorical indicator.
#' @param posteriors Individual profile posteriors.
#' @param blocks The fitted response probabilities, one matrix per indicator.
#' @param group_index Group indices, or `NULL` for a pooled total.
#' @return A matrix with one row per group (or one row overall) and one column
#'   per free response parameter, in `.multilpa_coefficients()` order.
#' @noRd
.multilpa_response_scores <- function(codes, posteriors, blocks, group_index = NULL) {
  if (is.null(blocks) || length(blocks) == 0L) {
    rows <- if (is.null(group_index)) 1L else max(group_index)
    return(matrix(numeric(0), rows, 0L))
  }
  pieces <- lapply(seq_along(blocks), function(indicator) {
    block <- blocks[[indicator]]
    code <- codes[, indicator]
    observed <- !is.na(code)
    free <- seq_len(ncol(block) - 1L)
    columns <- lapply(seq_len(nrow(block)), function(profile) {
      weight <- posteriors[, profile]
      vapply(free, function(category) {
        indicator_value <- as.numeric(observed & code == category)
        weight * (indicator_value - observed * block[profile, category])
      }, numeric(length(code)))
    })
    do.call(cbind, columns)
  })
  contribution <- do.call(cbind, pieces)
  if (is.null(group_index)) {
    matrix(colSums(contribution), 1L, ncol(contribution))
  } else rowsum(contribution, group_index, reorder = FALSE)
}

#' Differentiate the observed mixture likelihood
#' @param theta Unconstrained parameter vector.
#' @param x Centered numeric indicator matrix, possibly containing NA.
#' @param object Fitted model defining dimensions and groups.
#' @return The gradient of the negative observed-data log likelihood.
#' @noRd
.multilpa_score <- function(theta, x, object, codes = NULL) {
  stopifnot(is.numeric(theta), is.matrix(x), inherits(object, "multilpa"))
  parameters <- .multilpa_decode(theta, object)
  expectation <- .multilpa_expectation(x, object$group_index, parameters, codes,
                                       unname(object$sampling_weights),
                                       object$extra_data)
  if (.multilpa_uses_chart(object)) {
    measurement <- .multilpa_chart_scores(
      parameters, expectation, x, object,
      theta[length(parameters$means) +
              seq_len(.multilpa_coordinate_widths(object, "unconstrained")[["variances"]])])
    mean_score <- as.vector(measurement$means)
    variance_score <- as.vector(measurement$covariances)
  } else if (identical(object$covariance_model, "full")) {
    measurement <- .multilpa_full_measurement_score(parameters, expectation, object)
    mean_score <- measurement$means
    variance_score <- measurement$covariances
  } else {
  observed <- !is.na(x)
  measurement_scores <- lapply(seq_len(object$n_profiles), function(profile) {
    stopifnot(is.numeric(profile), length(profile) == 1L)
    residuals <- sweep(x, 2L, parameters$means[profile, ], "-")
    residuals[!observed] <- 0
    weights <- expectation$subject_posteriors[, profile]
    list(means = colSums(sweep(residuals, 2L, parameters$variances[profile, ], "/") * weights),
      variances = 0.5 * colSums((sweep(residuals^2, 2L,
        parameters$variances[profile, ], "/") - observed) * weights))
  })
  mean_score <- unlist(lapply(measurement_scores, `[[`, "means"), use.names = FALSE)
  variance_score <- if (object$variance_model == "equal") {
    Reduce(`+`, lapply(measurement_scores, `[[`, "variances"))
  } else unlist(lapply(measurement_scores, `[[`, "variances"), use.names = FALSE)
  }
  profile_score <- unlist(lapply(seq_len(object$n_group_classes), function(id) {
    stopifnot(is.numeric(id), length(id) == 1L)
    counts <- colSums(expectation$joint[[id]])
    (counts - sum(counts) * parameters$profile_probabilities[id, ])[
      seq_len(object$n_profiles - 1L)]
  }), use.names = FALSE)
  group_score <- (colSums(expectation$group_posteriors) -
    sum(object$sampling_weights %||% object$n_groups) *
      parameters$group_probabilities)[seq_len(object$n_group_classes - 1L)]
  response_score <- as.vector(.multilpa_response_scores(
    codes, expectation$subject_posteriors, parameters$response_probabilities))
  extra_score <- as.vector(.latents_extra_scores(
    object$extra_data, expectation$subject_posteriors, parameters))
  -c(mean_score, variance_score, response_score, extra_score, profile_score,
     group_score)
}

#' Gaussian scores in log-Cholesky coordinates
#' @param parameters Decoded Gaussian and mixing parameters.
#' @param expectation Nested expectation step including Gaussian moments.
#' @param object A fitted full-covariance model.
#' @return Mean and log-Cholesky score vectors.
#' @noRd
.multilpa_full_measurement_score <- function(parameters, expectation, object) {
  stopifnot(is.list(parameters), is.list(expectation), inherits(object, "multilpa"),
            identical(object$covariance_model, "full"))
  dimension <- length(.multilpa_continuous_names(object))
  if (dimension == 0L) return(list(means = numeric(0), covariances = numeric(0)))
  lower <- lower.tri(matrix(0, dimension, dimension), diag = TRUE)
  component_scores <- lapply(seq_len(object$n_profiles), function(profile) {
    stopifnot(is.numeric(profile), length(profile) == 1L)
    covariance <- matrix(parameters$covariances[, , profile], dimension, dimension)
    precision <- chol2inv(chol(covariance))
    moments <- expectation$gaussian_moments[[profile]]
    weights <- expectation$subject_posteriors[, profile]
    residuals <- sweep(moments$expected, 2L, parameters$means[profile, ], "-")
    scatter <- crossprod(residuals, residuals * weights) +
      Reduce(`+`, lapply(moments$adjustments, function(adjustment) {
        stopifnot(is.list(adjustment))
        adjustment$covariance * sum(weights[adjustment$rows])
      }))
    list(means = as.vector(precision %*% colSums(residuals * weights)),
      covariance = 0.5 * (precision %*% scatter %*% precision - sum(weights) * precision))
  })
  profiles <- if (object$variance_model == "equal") 1L else seq_len(object$n_profiles)
  covariance_scores <- lapply(profiles, function(profile) {
    stopifnot(is.numeric(profile), length(profile) == 1L)
    covariance_score <- if (object$variance_model == "equal") {
      Reduce(`+`, lapply(component_scores, `[[`, "covariance"))
    } else component_scores[[profile]]$covariance
    factor <- t(chol(matrix(parameters$covariances[, , profile], dimension, dimension)))
    factor_score <- 2 * covariance_score %*% factor
    diag(factor_score) <- diag(factor_score) * diag(factor)
    factor_score[lower]
  })
  list(means = unlist(lapply(component_scores, `[[`, "means"), use.names = FALSE),
       covariances = unlist(covariance_scores, use.names = FALSE))
}

#' Delta-method Jacobian for mixture parameters
#' @param object A fitted model.
#' @return Jacobian mapping unconstrained to natural coefficients.
#' @noRd
.multilpa_inference_jacobian <- function(object) {
  stopifnot(inherits(object, "multilpa"))
  natural <- .multilpa_coefficients(object, "natural")
  theta <- .multilpa_coefficients(object, "unconstrained")
  n_means <- length(object$means)
  full_covariance <- identical(object$covariance_model, "full")
  dimension <- length(.multilpa_continuous_names(object))
  n_covariance <- if (full_covariance) dimension * (dimension + 1L) / 2L else dimension
  n_variances <- .multilpa_coordinate_widths(object, "natural")[["variances"]]
  n_chart <- .multilpa_coordinate_widths(object, "unconstrained")[["variances"]]
  n_measurement <- n_means + n_variances
  blocks <- object$response_probabilities %||% list()
  natural_response <- sum(vapply(blocks, length, numeric(1)))
  free_response <- .multilpa_response_width(object)
  n_extra <- .latents_extra_width(object$extra_data, object$n_profiles)
  natural_offset <- n_measurement + natural_response + n_extra
  free_offset <- n_means + n_chart + free_response + n_extra
  jacobian <- matrix(0, length(natural), length(theta), dimnames = list(names(natural), names(theta)))
  if (n_extra > 0L) {
    jacobian[n_measurement + natural_response + seq_len(n_extra),
             n_means + n_chart + free_response + seq_len(n_extra)] <-
      .latents_extra_jacobian(object, object$extra_data, object$n_profiles)
  }
  diag(jacobian)[seq_len(n_means)] <- 1
  if (.multilpa_uses_chart(object)) {
    jacobian[n_means + seq_len(n_variances), n_means + seq_len(n_chart)] <-
      .multilpa_chart_natural_jacobian(object)
  } else if (full_covariance && dimension > 0L) {
    lower <- lower.tri(matrix(0, dimension, dimension), diag = TRUE)
    positions <- which(lower, arr.ind = TRUE)
    invisible(lapply(seq_len(n_variances / n_covariance), function(profile) {
      stopifnot(is.numeric(profile), length(profile) == 1L)
      factor <- t(chol(matrix(object$covariances[, , profile], dimension, dimension)))
      block <- vapply(seq_len(n_covariance), function(index) {
        stopifnot(is.numeric(index), length(index) == 1L)
        derivative <- matrix(0, dimension, dimension)
        row <- positions[index, 1L]
        column <- positions[index, 2L]
        derivative[row, column] <- if (row == column) factor[row, column] else 1
        covariance_derivative <- derivative %*% t(factor) + factor %*% t(derivative)
        covariance_derivative[lower]
      }, numeric(n_covariance))
      indices <- n_means + (profile - 1L) * n_covariance + seq_len(n_covariance)
      jacobian[indices, indices] <<- block
    }))
  } else {
    diag(jacobian)[n_means + seq_len(n_variances)] <- natural[n_means + seq_len(n_variances)]
  }
  ## Each profile-by-indicator row is its own simplex, with the same
  ## diag(p) - p p' derivative the mixing weights use.
  natural_at <- n_measurement
  free_at <- n_means + n_chart
  invisible(lapply(blocks, function(block) {
    invisible(lapply(seq_len(nrow(block)), function(profile) {
      rows <- natural_at + seq_len(ncol(block))
      columns <- free_at + seq_len(ncol(block) - 1L)
      jacobian[rows, columns] <<- .inference_simplex_jacobian(block[profile, ], "last")
      natural_at <<- natural_at + ncol(block)
      free_at <<- free_at + ncol(block) - 1L
    }))
  }))
  invisible(lapply(seq_len(object$n_group_classes), function(id) {
    stopifnot(is.numeric(id), length(id) == 1L)
    natural_rows <- natural_offset + (id - 1L) * object$n_profiles + seq_len(object$n_profiles)
    free_columns <- free_offset + (id - 1L) * (object$n_profiles - 1L) + seq_len(object$n_profiles - 1L)
    jacobian[natural_rows, free_columns] <<-
      .inference_simplex_jacobian(object$profile_probabilities[id, ], "last")
  }))
  natural_rows <- natural_offset + object$n_group_classes * object$n_profiles + seq_len(object$n_group_classes)
  free_columns <- free_offset + object$n_group_classes * (object$n_profiles - 1L) + seq_len(object$n_group_classes - 1L)
  jacobian[natural_rows, free_columns] <-
    .inference_simplex_jacobian(object$group_probabilities, "last")
  jacobian
}

#' Observed-information inference for multilevel LPA
#'
#' Computes ordinary maximum-likelihood standard errors from the inverse
#' observed Hessian and delta-method Wald confidence intervals. These are local,
#' asymptotic estimates conditional on the selected numbers of classes. They are
#' not robust sandwich errors and do not account for model selection. Mixture
#' boundary solutions and unidentified Hessians do not admit this calculation.
#'
#' @param x A converged `multilpa` fit with inactive variance bounds.
#' @param data Optional. The data frame the model was fitted to; when omitted
#'   it is rebuilt from the indicators, identifiers and occasions the fit
#'   stores, which round-trip exactly. Supplying it is the stronger check that
#'   the caller still holds that frame.
#'   A supplied frame is checked exactly against the stored indicators and
#'   identifiers; for an older fit without stored indicators, the likelihood
#'   provides a weaker consistency check.
#' @param level Confidence level strictly between zero and one.
#' @param step Positive finite-difference step for the analytic score derivative.
#' @param vcov_type `"observed"` inverts the observed information.
#'   `"robust"` returns the Huber-White sandwich `A^-1 B A^-1`, where `B`
#'   accumulates the outer product of the per-group scores. Groups are the
#'   independent units, so robust errors relax the assumption that the Gaussian
#'   within-group model is correctly specified. They do not relax the assumption
#'   that groups are independent.
#'   `"opg"` inverts the outer product of the per-group scores (the BHHH or
#'   outer-product-of-gradients estimate), which estimates the information by
#'   the variance of the score rather than by the curvature of the likelihood.
#'   It is the estimator `glca` reports, so it reproduces that package's
#'   standard errors; the two agree with `"observed"` in large samples and can
#'   differ noticeably in small ones, where `"observed"` is usually the more
#'   reliable. `"robust"` and `"opg"` need more groups than parameters.
#' @param adjust Multiplicity correction applied to `p_value` to produce
#'   `p_adjusted`, one of the methods [stats::p.adjust()] accepts. The default
#'   `"none"` leaves the two columns equal: a correction changes what a p-value
#'   means, so it is applied only when it is asked for, and the method that was
#'   applied is recorded in the `adjust` attribute. The correction is taken
#'   over the tests the table actually reports; bounded parameters carry no test
#'   and do not count towards the family.
#' @param method How uncertainty is measured. `"wald"`, the default, inverts the
#'   observed information in the estimation coordinates, and is available for
#'   all fourteen covariance structures. EEI, VVI, EEE and VVV are estimated in
#'   log variances or log-Cholesky coordinates; the ten structures that
#'   constrain the volume, the shape or the orientation across profiles are
#'   estimated in log volumes, log shapes whose determinant-one constraint is
#'   written into the coordinates, and Cayley-transform orientations (or, for
#'   VEE and EVV, determinant-one log-Cholesky factors), so the information has
#'   one row per free parameter of the structure. The reported variances and
#'   covariances are then carried from those coordinates by the delta method,
#'   and their natural-scale covariance is singular, as it is for the class
#'   probabilities. `"bootstrap"` resamples *groups* with replacement, refits inside
#'   the same covariance family, undoes the relabelling each refit comes back
#'   with, and reports the percentile interval and the standard deviation of the
#'   replicates. It is available for all fourteen structures. Groups are the resampling unit
#'   rather than rows, so the interval carries the same independence assumption
#'   as `vcov_type = "robust"` and not the stronger one `"observed"` makes.
#' @param iter Number of resamples when `method = "bootstrap"`, at least two.
#'   The default 199 is enough for a standard error; a 95% percentile interval
#'   is steadier at 999 and above.
#' @param n_starts,max_iter,tol Passed to each bootstrap refit. Each replicate
#'   is a fresh fit, so `n_starts` buys the same protection against a local
#'   maximum here as it does in [multilpa()], at the same multiple of the cost.
#' @param seed Optional seed for the resampling: any whole number `set.seed()`
#'   accepts; a fraction raises `latents_bad_argument`. The stream is restored on exit,
#'   so a seeded call leaves the caller's random state exactly as it found it.
#' @param boundary What to do when categorical response probabilities sit on
#'   their lower bound, `min_probability`. There the likelihood is flat in the
#'   logit and the information is singular, so `"error"`, the default, refuses
#'   with `latents_boundary_fit`. `"fix"` holds those probabilities at the bound
#'   and reports every other parameter conditionally on them, the convention
#'   Mplus follows; the held probabilities are left out of the table (or, for a
#'   covariate fit, reported with `NA` standard errors) and named in the
#'   result's `fixed_at_bound` attribute.
#' @return A base `data.frame` with one row per reported parameter and the
#'   columns `level` (`"measurement"`, `"profile"` or `"group"`), `outcome`,
#'   `term`, `parameter`, `estimate`, `standard_error`, `statistic`, `p_value`,
#'   `p_adjusted`, `conf_low` and `conf_high`. Estimates are on the natural
#'   scale: variances in their own units, probabilities as probabilities. A
#'   parameter whose null value is on the boundary of its space -- a variance, a
#'   probability, a residual variance on the covariance diagonal -- carries `NA`
#'   for `statistic`, `p_value` and `p_adjusted`, because a Wald test against
#'   that boundary is not a question worth asking; the interval still is. All
#'   class probabilities are reported; their sum constraints make the natural
#'   covariance singular. Wald intervals for probabilities are formed on the
#'   logit scale and for variances (and covariance diagonals) on the log
#'   scale, then mapped back with the same standard error, so they stay inside
#'   the parameter's range and widen towards the far side of a bound; every
#'   other interval is `estimate +/- z * standard_error`. `confint()` returns
#'   the same intervals. A probability at or below 1e-6 is treated as on its
#'   bound (refused, or held by `boundary = "fix"`).
#'
#'   A fit made with `fixed` reports only the parameters it estimated: a held
#'   measurement block is a constant of this likelihood, so it contributes no
#'   row here and no row or column to the information matrix. The table is on
#'   the natural scale, where every class probability is reported, so it has one
#'   row per estimated natural coefficient: `n_parameters` rows plus one for
#'   each set of probabilities whose reference category the estimation scale
#'   drops. The held values still enter the likelihood at
#'   the values they were held at, so every standard error, interval and p-value
#'   is conditional on that measurement solution and does not propagate its
#'   uncertainty. See [fit_staged()] for what that conditioning means. For a
#'   [fit_staged()] result, `method = "bootstrap"` does propagate it: each
#'   resample refits both stages, and the table has a row for every
#'   parameter, the measurement included (attribute `stages_resampled` is 2).
#'
#'   With `method = "bootstrap"` the table has the same columns and the same
#'   rows, `estimate` is still the fitted value, `standard_error` is the
#'   standard deviation of the replicates and `conf_low`/`conf_high` are the
#'   percentile interval. `statistic`, `p_value` and `p_adjusted` are `NA`
#'   throughout: putting a normal approximation back on top of the replicates
#'   the interval was read from is the assumption this path exists to avoid, so
#'   the interval is the inference. Its attributes are `covariance` (the
#'   covariance of the replicates, which `vcov()` returns), `level`, `method`,
#'   `iter`, `n_valid`, `replicates` (the kept replicates, one row each),
#'   `messages` (why a resample was dropped, `NA` where it was not),
#'   `structure` and `fixed`; `covariance_unconstrained` is `NULL`, because the
#'   estimation-scale chart is what this path does without.
#'
#'   Diagnostics of the fit as a whole travel as attributes rather than as
#'   columns repeated down every row: `covariance` and `covariance_unconstrained`
#'   (natural and estimation-scale covariance of the estimates), `hessian`,
#'   `gradient`, `scaled_score`, `condition_ratio`, `level`, `step`, `vcov_type`,
#'   `fixed` (the measurement blocks this fit held, `character()` when none) and
#'   `adjust`. When `vcov_type` is `"robust"`, `group_scores` holds the
#'   groups-by-parameters score matrix and `scaling_correction` the MLR scaling
#'   correction factor `tr(A^-1 B) / q`, used for scaled likelihood-ratio
#'   difference tests.
#' @section Conditions:
#'   `latents_no_converge` for an unconverged fit, `latents_boundary_fit` when
#'   a variance, a response probability or a mixing probability sits at its
#'   bound, `latents_singular_information` when the observed information cannot
#'   be inverted, `latents_no_free_parameters` when `fixed` held every
#'   parameter, `latents_bad_inference_data` when a supplied `data` does not
#'   reproduce the fit, and `latents_too_few_groups` for `vcov_type = "robust"`
#'   with fewer independent groups than reported parameters. A fit whose score
#'   is still far from zero is reported with a `latents_unconverged` warning
#'   rather than refused. A fit made with `multilpa(prior = )` is refused by
#'   the Wald path with `latents_unsupported_inference` (it sits at a
#'   posterior mode, where the likelihood's score is not zero), and a fit with
#'   a noise component by either path with `latents_unsupported_noise`.
#'
#'   `method = "bootstrap"` adds `latents_unsupported_inference` for a fit that
#'   holds a measurement block through `multilpa(fixed = )` --- the held values
#'   came from another fit and resampling these data does not resample them;
#'   a [fit_staged()] result is bootstrapped by staging again instead --- and
#'   `latents_bootstrap_failed` when fewer than two resamples produced a usable
#'   fit, whose message carries the first reason one gave. Resamples that fail
#'   or do not converge are dropped with a `latents_bootstrap_dropped` warning
#'   naming how many, rather than being silently left out of the count.
#' @examples
#' set.seed(42)
#' example_data <- data.frame(
#'   school = rep(seq_len(10), each = 10),
#'   score_a = rnorm(100), score_b = rnorm(100)
#' )
#' fit <- multilpa(example_data, c("score_a", "score_b"), "school",
#'                 n_profiles = 2, n_group_classes = 1, n_starts = 2, seed = 1)
#' parameter_inference(fit)
#'
#' # Many tests in one table: name the correction, do not apply one by stealth.
#' parameter_inference(fit, adjust = "BH")
#'
#' # A structure that constrains the shape across profiles (VEI) is charted in
#' # its own free coordinates, so it has Wald standard errors too.
#' shaped <- multilpa(example_data, c("score_a", "score_b"), "school",
#'                    n_profiles = 2, n_group_classes = 1, n_starts = 2, seed = 1,
#'                    volume = "varying", shape = "equal", orientation = "axis")
#' parameter_inference(shaped)
#'
#' # The bootstrap resamples schools and refits inside the same family.
#' parameter_inference(shaped, method = "bootstrap", iter = 10, n_starts = 1,
#'                     seed = 1)
#' @export
parameter_inference <- function(x, data = NULL, level = 0.95, step = 1e-4,
                                vcov_type = c("observed", "robust", "opg"),
                                adjust = .multilpa_p_adjust_methods,
                                method = c("wald", "bootstrap"), iter = 199L,
                                n_starts = 10L, max_iter = 1000L, tol = 1e-8,
                                seed = NULL, boundary = c("error", "fix")) {
  UseMethod("parameter_inference")
}

#' @rdname parameter_inference
#' @export
parameter_inference.multilpa <- function(x, data = NULL, level = 0.95, step = 1e-4,
                             vcov_type = c("observed", "robust", "opg"),
                             adjust = .multilpa_p_adjust_methods,
                             method = c("wald", "bootstrap"), iter = 199L,
                             n_starts = 10L, max_iter = 1000L, tol = 1e-8,
                             seed = NULL, boundary = c("error", "fix")) {
  stopifnot(inherits(x, "multilpa"))
  .multilpa_refuse_noise(x, "parameter_inference()")
  boundary <- match.arg(boundary)
  data <- .multilpa_resolve_data(x, data)
  stopifnot(is.data.frame(data),
            is.numeric(level), length(level) == 1L, is.finite(level), level > 0, level < 1,
            is.numeric(step), length(step) == 1L, is.finite(step), step > 0)
  vcov_type <- .latents_weighted_vcov(!is.null(x$sampling_weights),
                                      match.arg(vcov_type), missing(vcov_type))
  adjust <- match.arg(adjust)
  method <- match.arg(method)
  if (identical(method, "bootstrap")) {
    stopifnot(
      "`iter` must be a single integer of at least two" =
        is.numeric(iter) && length(iter) == 1L && is.finite(iter) &&
        iter >= 2 && iter == floor(iter),
      "`n_starts` must be a single positive integer" =
        is.numeric(n_starts) && length(n_starts) == 1L && is.finite(n_starts) &&
        n_starts >= 1 && n_starts == floor(n_starts),
      "`max_iter` must be a single positive integer" =
        is.numeric(max_iter) && length(max_iter) == 1L && is.finite(max_iter) &&
        max_iter >= 1 && max_iter == floor(max_iter),
      "`tol` must be a single positive number" =
        is.numeric(tol) && length(tol) == 1L && is.finite(tol) && tol > 0)
    .multilpa_check_seed(seed)
    .latents_local_seed(seed, after = FALSE)
    return(.multilpa_bootstrap_inference(x, data, level, as.integer(iter),
                                         as.integer(n_starts),
                                         as.integer(max_iter), tol, adjust))
  }
  .multilpa_check_regularity(x, vcov_type, boundary)
  .multilpa_check_structure_inference(x)
  if (!is.null(x$prior)) {
    stop(errorCondition(paste(
      "Wald standard errors invert the curvature of the likelihood at its",
      "maximum, and a fit made with `prior` sits at the posterior mode instead,",
      "where the likelihood's score is not zero. Use `method = \"bootstrap\"`,",
      "which refits every resample with the same prior."),
      class = "latents_unsupported_inference", call = NULL))
  }
  free <- .multilpa_free_index(x, "unconstrained")
  free_natural <- .multilpa_free_index(x, "natural")
  counted_free <- length(free)
  if (length(free) == 0L) {
    stop(errorCondition(
      "This fit holds every parameter it has, so there is nothing to report a standard error for.",
      class = "latents_no_free_parameters", call = NULL))
  }
  stopifnot("the free coordinates must match the parameters the fit counts" =
              counted_free == x$n_parameters)
  ## Response probabilities on their bound are held there, like a block the
  ## fit held, and the rest is inferred conditionally on them.
  at_bound <- if (identical(boundary, "fix")) .multilpa_response_bound(x) else
    list(natural = character(), unconstrained = character())
  free <- setdiff(free, match(at_bound$unconstrained,
                              names(.multilpa_coefficients(x, "unconstrained"))))
  free_natural <- setdiff(free_natural, match(
    at_bound$natural, names(.multilpa_coefficients(x, "natural"))))
  full_theta <- .multilpa_coefficients(x, "unconstrained")
  theta <- full_theta[free]
  prepared <- .multilpa_inference_matrix(x, data)
  observed <- prepared$x
  centered_object <- x
  centered_object$means <- sweep(x$means, 2L, prepared$centers, "-")
  centered_full <- .multilpa_coefficients(centered_object, "unconstrained")
  ## A held block is a constant of this likelihood, not a parameter: it is put
  ## back at its held value on every evaluation, so the model is conditional on
  ## the measurement rather than fitted without it.
  restore <- .multilpa_restore_held(centered_full, free)
  centered_theta <- unname(centered_full)[free]
  codes <- prepared$codes
  objective <- function(parameters) {
    stopifnot(is.numeric(parameters))
    -.multilpa_expectation(observed, x$group_index,
      .multilpa_decode(restore(parameters), x), codes,
      unname(x$sampling_weights), x$extra_data)$log_likelihood
  }
  score <- function(parameters) {
    stopifnot(is.numeric(parameters))
    .multilpa_score(restore(parameters), observed, x, codes)[free]
  }
  fitted_likelihood <- -objective(centered_theta)
  if (abs(fitted_likelihood - x$log_likelihood) > 1e-8 * (1 + abs(x$log_likelihood))) {
    stop(errorCondition(
      "data do not reproduce the fitted log likelihood; supply the original fitting data.",
      class = "latents_bad_inference_data", call = NULL))
  }
  parameter_scale <- c(as.vector(t(sqrt(x$variances))),
                       rep(1, length(full_theta) - length(x$means)))
  dimension <- length(.multilpa_continuous_names(x))
  if (.multilpa_uses_chart(x)) {
    chart_scale <- .multilpa_chart_encode(x)$scale
    parameter_scale[length(x$means) + seq_along(chart_scale)] <- chart_scale
  } else if (identical(x$covariance_model, "full") && dimension > 0L) {
    # Shared with the covariate path, which lacked this scaling and reported a
    # singular information for an indicator in large units. One rule, one place.
    covariance_scale <- .multilpa_covariance_coordinate_scale(
      x$variances, dimension,
      if (x$variance_model == "equal") 1L else seq_len(x$n_profiles))
    parameter_scale[length(x$means) + seq_along(covariance_scale)] <- covariance_scale
  }
  parameter_scale <- parameter_scale[free]
  scaled_objective <- function(displacement) {
    stopifnot(is.numeric(displacement))
    objective(centered_theta + displacement * parameter_scale)
  }
  scaled_gradient <- function(displacement) {
    stopifnot(is.numeric(displacement))
    score(centered_theta + displacement * parameter_scale) * parameter_scale
  }
  information <- .multilpa_observed_hessian(scaled_objective, scaled_gradient,
                                          parameter_scale, step)
  scaled_hessian <- information$scaled
  hessian <- information$natural
  condition_ratio <- information$condition_ratio
  scaled_inverse <- information$inverse
  group_scores <- NULL
  scaling_correction <- NA_real_
  if (vcov_type %in% c("robust", "opg")) {
    group_scores <- .multilpa_group_scores(restore(centered_theta), observed,
                                           x, codes)[, free, drop = FALSE]
    scaled_cross <- .multilpa_cross_product(sweep(group_scores, 2L, parameter_scale, "*"))
    scaling_correction <- sum(diag(scaled_inverse %*% scaled_cross)) / length(theta)
    scaled_inverse <- if (identical(vcov_type, "opg")) {
      .multilpa_opg_inverse(scaled_cross)
    } else .inference_sandwich(scaled_inverse, scaled_cross)
  }
  covariance_unconstrained <- scaled_inverse * tcrossprod(parameter_scale)
  dimnames(hessian) <- dimnames(covariance_unconstrained) <- list(names(theta), names(theta))
  ## The natural coordinates of a held block depend only on the held
  ## unconstrained ones, so restricting the Jacobian to the free rows and
  ## columns is the delta method for the free parameters, not an approximation
  ## of it.
  jacobian <- .multilpa_inference_jacobian(x)[free_natural, free, drop = FALSE]
  covariance <- .inference_delta_covariance(jacobian, covariance_unconstrained)
  standard_errors <- sqrt(pmax(diag(covariance), 0))
  estimates <- .multilpa_coefficients(x, "natural")[free_natural]
  critical <- .inference_critical(level, "upper")
  intervals <- cbind(estimates - critical * standard_errors, estimates + critical * standard_errors)
  colnames(intervals) <- paste0(format(100 * c((1 - level) / 2, (1 + level) / 2), trim = TRUE), "%")
  gradient <- stats::setNames(score(centered_theta), names(theta))
  scaled_score <- max(abs(gradient * parameter_scale))
  ## How far the reported estimate sits from the stationary point, measured in
  ## its own standard errors: a score `g` against information `I` displaces the
  ## estimate by `g / I`, and the standard error is `sqrt(1 / I)`, so `g * SE`
  ## is that displacement in standard-error units. It is dimensionless and it
  ## accounts for the sample size, which a fixed cut on the scaled score does
  ## not -- at the default `tol = 1e-8` on 720 observations the scaled score is
  ## 1.19e-02 and used to warn, while the displacement it implies is 1.7e-03,
  ## under a fifth of one percent of a standard error and unable to move any
  ## reported figure. A genuinely loose fit still trips it: `tol = 1e-4` on the
  ## same data gives 5.0e-02, five percent of a standard error.
  standard_errors_unconstrained <- sqrt(pmax(diag(covariance_unconstrained), 0))
  score_displacement <- max(abs(gradient) * standard_errors_unconstrained)
  if (is.finite(score_displacement) && score_displacement > 0.01) {
    warning(warningCondition(sprintf(
      paste("The fitted likelihood still carries a score worth %.1f%% of a",
            "standard error; refit with a tighter `tol` before using Wald",
            "inference."), 100 * score_displacement),
      class = "latents_unconverged", call = NULL))
  }
  statistic <- unname(estimates / standard_errors)
  result <- data.frame(
    .multilpa_coefficient_labels(x, "natural")[free_natural, , drop = FALSE],
    estimate = unname(estimates), standard_error = unname(standard_errors),
    statistic = statistic, p_value = 2 * stats::pnorm(-abs(statistic)),
    conf_low = unname(intervals[, 1L]), conf_high = unname(intervals[, 2L]),
    row.names = NULL, stringsAsFactors = FALSE)
  continuous <- .multilpa_continuous_names(x)
  bounds <- .multilpa_wald_bounds(
    result$estimate, result$standard_error,
    .multilpa_interval_kind(result$parameter, result$term, continuous), critical)
  result$conf_low <- bounds$low
  result$conf_high <- bounds$high
  ## A Wald test of a variance against zero is not a question worth asking; the
  ## interval still is.
  bounded <- result$parameter %in% c("variance", "probability", "response",
                                      "count_mean", "count_dispersion") |
    (result$parameter == "covariance" &
       result$term %in% paste(continuous, continuous, sep = ":"))
  result$statistic[bounded] <- NA_real_
  result$p_value[bounded] <- NA_real_
  result <- .multilpa_adjust_p(result, adjust)
  ## Diagnostics of the fit, not of any one parameter, so they travel as
  ## attributes rather than as columns repeated down every row.
  attributes(result) <- c(attributes(result), list(
    covariance = covariance, covariance_unconstrained = covariance_unconstrained,
    hessian = hessian, gradient = gradient, scaled_score = scaled_score,
    score_displacement = score_displacement,
    condition_ratio = condition_ratio, level = level, step = step,
    method = "wald", vcov_type = vcov_type, group_scores = group_scores,
    scaling_correction = scaling_correction,
    fixed = x$fixed %||% character(),
    fixed_at_bound = at_bound$natural))
  result
}

#' Response probabilities sitting on their lower bound
#'
#' A probability held at `min_probability` has a logit on the edge of the
#' space, where the likelihood is flat and the information singular. The free
#' logit of a category is on the bound when that category's probability is, or
#' when the reference (last) category's is, since every logit of that row is then
#' measured against a vanishing denominator; in the second case every
#' probability of the row is on the bound too.
#'
#' @param object A fitted model with `response_probabilities`.
#' @return A list of two character vectors of parameter names, `natural`
#'   (probabilities) and `unconstrained` (logits), in the
#'   `level.parameter.outcome.term` spelling every fitted class uses.
#' @noRd
.multilpa_response_bound <- function(object) {
  blocks <- object$response_probabilities %||% list()
  floor <- .multilpa_probability_floor(object)
  pieces <- lapply(names(blocks), function(indicator) {
    block <- blocks[[indicator]]
    categories <- colnames(block)
    at <- block <= floor
    held <- at | at[, ncol(block)]
    spell <- function(positions, kind) {
      sprintf("measurement.%s.profile_%d.%s:%s", kind, positions[, 1L],
              indicator, categories[positions[, 2L]])
    }
    list(natural = spell(which(held, arr.ind = TRUE), "response"),
         unconstrained = spell(which(held[, -ncol(block), drop = FALSE],
                                     arr.ind = TRUE), "response_logit"))
  })
  list(natural = unlist(lapply(pieces, `[[`, "natural")) %||% character(),
       unconstrained = unlist(lapply(pieces, `[[`, "unconstrained")) %||% character())
}

#' The probability below which an estimate is on its boundary
#'
#' EM approaches a zero probability slowly, so a fit rarely lands exactly on
#' `min_probability`; it stops a little above it, at a logit of -15 or -20
#' where the likelihood is flat. Treating only the exact floor as the bound
#' left such an estimate free, and the observed information was then singular.
#' At or below 1e-6 a probability's expected count is far below one
#' observation at any realistic sample size, so the data cannot tell it from
#' zero and it is treated as on its bound.
#' @param object A fitted model.
#' @return A single probability.
#' @noRd
.multilpa_probability_floor <- function(object) {
  max((object$min_probability %||% 1e-10) * (1 + 1e-7), 1e-6)
}

#' @rdname latents-model-methods
#' @export
#' @importFrom stats coef
coef.multilpa <- function(object, scale = c("natural", "unconstrained"), ...) {
  stopifnot(inherits(object, "multilpa"))
  .multilpa_coefficients(object, match.arg(scale))
}

#' @rdname latents-model-methods
#' @export
#' @importFrom stats vcov
vcov.multilpa <- function(object, data = NULL, scale = c("natural", "unconstrained"), ...) {
  stopifnot(inherits(object, "multilpa"))
  scale <- match.arg(scale)
  # `data` is no longer required: a fit carries the columns it was built from,
  # and `parameter_inference()` falls back to them.
  information <- parameter_inference(object, data, ...)
  if (scale == "natural") attr(information, "covariance") else
    attr(information, "covariance_unconstrained")
}

#' @rdname latents-model-methods
#' @export
#' @importFrom stats confint
confint.multilpa <- function(object, parm, level = 0.95, data = NULL, ...) {
  stopifnot(inherits(object, "multilpa"), is.numeric(level), length(level) == 1L,
            is.finite(level), level > 0, level < 1)
  estimates <- coef.multilpa(object)
  ## One call, not one through `vcov()`: the bootstrap interval is the
  ## percentile interval this table already carries, and reaching it through
  ## the covariance would rebuild it as `estimate +/- z * se`, a different
  ## interval from the one `parameter_inference()` reports for the same fit.
  information <- parameter_inference(object, data = data, level = level, ...)
  covariance <- attr(information, "covariance")
  ## A held measurement coefficient is reported by coef() -- it is part of the
  ## model -- but it was not estimated here and has no interval. The default is
  ## therefore what the covariance actually covers.
  estimated <- rownames(covariance)
  if (missing(parm)) parm <- estimated
  if (is.numeric(parm)) {
    if (anyNA(parm) || any(!is.finite(parm)) || any(parm != floor(parm)) ||
        any(parm < 1) || any(parm > length(estimates))) stop("Invalid coefficient indices in parm.")
    parm <- names(estimates)[parm]
  }
  if (!is.character(parm) || anyNA(parm) || !all(parm %in% names(estimates))) {
    stop("parm must identify existing coefficient names or indices.")
  }
  held <- setdiff(parm, estimated)
  if (length(held) > 0L) {
    stop(errorCondition(sprintf(
      "%s %s held fixed by this fit, so %s carr%s no confidence interval.",
      paste(sprintf("`%s`", held), collapse = ", "),
      if (length(held) == 1L) "was" else "were",
      if (length(held) == 1L) "it" else "they",
      if (length(held) == 1L) "ies" else "y"),
      class = "latents_held_parameter", call = NULL))
  }
  # The intervals parameter_inference() reports, whichever path made them:
  # percentile for the bootstrap, and for Wald ones formed where the parameter
  # is unbounded, so the two verbs can never disagree.
  intervals <- cbind(information$conf_low, information$conf_high)
  rownames(intervals) <- rownames(covariance)
  intervals <- intervals[parm, , drop = FALSE]
  colnames(intervals) <- paste0(format(100 * c((1 - level) / 2, (1 + level) / 2), trim = TRUE), "%")
  intervals
}

#' Check that a fit admits Wald inference at all
#'
#' Boundary solutions, unconverged fits and zero mixing probabilities each put
#' the estimate where the asymptotic normal approximation does not hold, so
#' they are refused rather than reported with a number.
#'
#' @return `NULL`, invisibly; raises on the first broken contract.
#' @noRd
.multilpa_check_regularity <- function(object, vcov_type, boundary = "error") {
  if (vcov_type %in% c("robust", "opg") && object$n_groups <= object$n_parameters) {
    stop(errorCondition(sprintf(
      "Robust and OPG inference need more groups than parameters; this fit has %d groups and %d parameters.",
      object$n_groups, object$n_parameters),
      class = "latents_too_few_groups", call = NULL))
  }
  if (!isTRUE(object$converged)) {
    stop(errorCondition("Inference requires a converged fit.",
                        class = "latents_no_converge", call = NULL))
  }
  ## A bound-active value that this fit held fixed is a constant of the
  ## conditional likelihood, not an estimate sitting on its boundary, so it does
  ## not disqualify the parameters that were actually estimated.
  held <- object$fixed %||% character()
  if (isTRUE(object$boundary) && !("variances" %in% held)) {
    stop(errorCondition("Wald inference is unavailable for a bound-active fit.",
                        class = "latents_boundary_fit", call = NULL))
  }
  ## `boundary = "fix"` asks for the bound-active response probabilities to be
  ## held at their bound and the rest estimated conditionally on them.
  response <- if ("response_probabilities" %in% held ||
                  identical(boundary, "fix")) NULL else
    unlist(object$response_probabilities, use.names = FALSE)
  if (length(response) > 0L &&
      any(response <= .multilpa_probability_floor(object))) {
    stop(errorCondition(
      paste("Wald inference is unavailable for bound-active categorical response",
            "probabilities. `boundary = \"fix\"` holds them at their bound and",
            "reports the other parameters conditionally on them."),
      class = "latents_boundary_fit", call = NULL))
  }
  if (length(object$count_means) > 0L && any(object$count_means <= 1e-8)) {
    stop(errorCondition(
      "Wald inference is unavailable when a count mean is estimated at zero.",
      class = "latents_boundary_fit", call = NULL))
  }
  if (length(object$count_dispersion) > 0L &&
      any(object$count_dispersion <= .latents_min_dispersion * (1 + 1e-6))) {
    stop(errorCondition(paste(
      "A negative-binomial dispersion is at its floor (the Poisson limit), where",
      "Wald inference does not apply; refit with `count_model = \"poisson\"`."),
      class = "latents_boundary_fit", call = NULL))
  }
  if (any(object$profile_probabilities <= 0) || any(object$group_probabilities <= 0)) {
    stop(errorCondition(
      "Wald inference requires strictly positive mixing probabilities.",
      class = "latents_boundary_fit", call = NULL))
  }
  invisible(NULL)
}

#' Give the caller's frame a single-level fit's internal row numbering
#'
#' A single-level fit numbered its rows itself; the caller's frame has no such
#' column, and its row order is what the numbering was. Frames that already
#' carry the column, or have a different number of rows, are returned as given
#' for the caller's own checks to judge.
#'
#' @param object A fitted model of this package.
#' @param data The caller's frame.
#' @return `data`, with the fit's identifier column added when it was absent.
#' @noRd
.multilpa_number_single_level <- function(object, data) {
  if (isTRUE(object$single_level) && !object$id %in% names(data) &&
      nrow(data) == object$n_observations) {
    data[[object$id]] <- object$group_values[seq_len(nrow(data))]
  }
  data
}

#' Recover and centre the indicator matrix the fit was built on
#'
#' The Hessian is evaluated at the fitted estimates, so it is only meaningful
#' if `data` really is the data that produced them. Stored indicators are
#' compared exactly; older fits without them fall back to the likelihood check
#' the caller performs next.
#'
#' @return A list with the centred matrix `x` and the `centers` removed from it.
#' @noRd
.multilpa_inference_matrix <- function(object, data) {
  data <- .multilpa_number_single_level(object, data)
  if (nrow(data) != object$n_observations ||
      !all(c(object$vars, object$id) %in% names(data)) ||
      anyDuplicated(names(data)) ||
      !all(vapply(data[, .multilpa_continuous_names(object), drop = FALSE],
                  is.numeric, logical(1)))) {
    stop(errorCondition(
      "data must contain the original numeric indicators and group column.",
      class = "latents_bad_inference_data", call = NULL))
  }
  group_index <- match(data[[object$id]], object$group_values)
  if (!identical(group_index, object$group_index)) {
    stop(errorCondition(
      "data must retain the original group identifiers and row order.",
      class = "latents_bad_inference_data", call = NULL))
  }
  ## Only the Gaussian indicators form the numeric matrix; the categorical ones
  ## are re-encoded from `data` rather than taken from the fit, so that supplying
  ## the wrong categorical columns is caught rather than silently accepted.
  continuous <- .multilpa_continuous_names(object)
  ## as.matrix() of a zero-column frame is logical, and the expectation demands
  ## a numeric matrix, so an all-categorical fit needs the mode forced.
  x <- if (length(continuous) == 0L) matrix(numeric(0), nrow(data), 0L) else
    .multilpa_center_like(object, as.matrix(data[, continuous, drop = FALSE]))
  if (any(is.infinite(x)) || any(is.nan(x)) ||
      (anyNA(x) && !identical(object$missing, "fiml"))) {
    stop(errorCondition(
      "data contain unsupported missing or non-finite indicators.",
      class = "latents_bad_inference_data", call = NULL))
  }
  if (!is.null(object$indicator_data) && ncol(x) > 0L &&
      !identical(x, object$indicator_data)) {
    stop(errorCondition(
      "data must reproduce the original indicator data, including row order and names.",
      class = "latents_bad_inference_data", call = NULL))
  }
  codes <- NULL
  if (length(object$categorical %||% character()) > 0L) {
    encoded <- .multilpa_encode_categorical(data[, object$categorical, drop = FALSE])
    codes <- encoded$codes
    if (!identical(unname(codes), unname(object$categorical_data)) ||
        !identical(encoded$levels, object$categorical_levels)) {
      stop(errorCondition(
        "data must reproduce the original categorical indicators, including their categories and row order.",
        class = "latents_bad_inference_data", call = NULL))
    }
  }
  if (!is.null(object$extra_data)) {
    extra <- .latents_prepare_extra(data, object$ordinal, object$count,
                                    object$missing %||% "error")
    if (!identical(unname(extra$ordinal), unname(object$extra_data$ordinal)) ||
        !identical(extra$ordinal_levels, object$extra_data$ordinal_levels) ||
        !identical(unname(extra$count), unname(object$extra_data$count))) {
      stop(errorCondition(
        "data must reproduce the original ordinal and count indicators.",
        class = "latents_bad_inference_data", call = NULL))
    }
  }
  centers <- if (ncol(x) > 0L) colMeans(x, na.rm = TRUE) else numeric(0)
  list(x = if (ncol(x) > 0L) sweep(x, 2L, centers, "-") else x,
       centers = centers, codes = codes)
}

#' Split a generated parameter name into tidy columns
#'
#' The names are machine-generated with a strict grammar -- `mean[profile,
#' indicator]`, `variance[profile,indicator]`, `covariance[profile,a,b]`,
#' `profile_probability[group_class,profile]`, `group_probability[group_class]`,
#' and their unconstrained counterparts `log_variance`, `cholesky`,
#' `log_cholesky`, `response_logit`, `profile_logit` and `group_logit` -- so
#' they can be taken apart reliably. Reporting the pieces as columns means a
#' caller never has to parse a label to learn which level a parameter belongs
#' to, which is the whole point of a tidy result.
#'
#' @param names Character vector of generated parameter names.
#' @param object The fit, supplying the profile and group-class counts.
#' @return A data frame with `level`, `outcome`, `term` and `parameter`, one row
#'   per name, in the order given.
#' @noRd
.multilpa_tidy_labels <- function(names, object) {
  stopifnot("`names` must be character" = is.character(names))
  prefix <- sub("\\[.*$", "", names)
  unknown <- setdiff(prefix, c(.multilpa_measurement_kinds,
                               "profile_probability", "profile_logit",
                               "group_probability", "group_logit"))
  stopifnot("every parameter name must use a known kind" = length(unknown) == 0L)
  inside <- sub("^[^\\[]*\\[", "", sub("\\]$", "", names))
  parts <- strsplit(inside, ",", fixed = TRUE)
  first <- vapply(parts, function(p) p[[1L]], character(1))
  second <- vapply(parts, function(p) if (length(p) >= 2L) p[[2L]] else NA_character_,
                   character(1))
  third <- vapply(parts, function(p) if (length(p) >= 3L) p[[3L]] else NA_character_,
                  character(1))
  profile_label <- function(value) ifelse(value == "shared", "shared",
                                          paste0("profile_", value))
  class_label <- function(value) paste0("group_class_", value)
  measurement <- prefix %in% .multilpa_measurement_kinds
  within_profile <- prefix %in% c("profile_probability", "profile_logit")
  data.frame(
    level = ifelse(measurement, "measurement",
                   ifelse(within_profile, "profile", "group")),
    outcome = ifelse(measurement, profile_label(first),
                     ifelse(within_profile,
                            profile_label(second), class_label(first))),
    term = ifelse(prefix %in% .multilpa_paired_kinds,
                  paste(second, third, sep = ":"),
                  ifelse(measurement, second,
                         ifelse(within_profile,
                                class_label(first), NA_character_))),
    parameter = ifelse(prefix %in% c("profile_probability", "group_probability"),
                       "probability",
                       ifelse(prefix %in% c("profile_logit", "group_logit"),
                              "logit", prefix)),
    row.names = NULL, stringsAsFactors = FALSE
  )
}

#' Parameter kinds that belong to the measurement model
#' @noRd
.multilpa_measurement_kinds <- c("mean", "variance", "log_variance",
                                 "covariance", "cholesky", "log_cholesky",
                                 "log_volume", "log_shape", "rotation",
                                 "shape_cholesky", "log_shape_cholesky",
                                 "response", "response_logit",
                                 "ordinal_intercept", "ordinal_location",
                                 "count_mean", "log_count_mean",
                                 "count_dispersion", "log_count_dispersion")

#' Parameter kinds whose term names a pair, written `a:b`
#' @noRd
.multilpa_paired_kinds <- c("covariance", "cholesky", "log_cholesky",
                            "rotation", "shape_cholesky", "log_shape_cholesky",
                            "response", "response_logit", "ordinal_intercept")

#' Multiplicity corrections an inference verb accepts
#'
#' The same methods [stats::p.adjust()] knows, with `"none"` first so that
#' `match.arg()` makes the uncorrected column the default. Applying a correction
#' silently would change what every p-value in the table means.
#'
#' @noRd
.multilpa_p_adjust_methods <- c("none",
                                setdiff(stats::p.adjust.methods, "none"))

Try the latents package in your browser

Any scripts or data that you put into this service are public.

latents documentation built on Oct. 16, 2026, 5:09 p.m.