R/procrustes-consensus.R

Defines functions .consensus_target_procrustes_single .orthogonal_procrustes .procrustes_orthogonal_T .consensus_loss .hyperplane_count .procrustes_validate_starts .procrustes_validate_start_index .procrustes_validate_oblique_controls .procrustes_validate_t_init .procrustes_validate_crossprod .procrustes_apply_dimnames .procrustes_names .procrustes_default_names .procrustes_validate_matrix_list .procrustes_validate_matrix_pair .procrustes_as_matrix .procrustes_check_flag .procrustes_check_integer_scalar .procrustes_check_numeric_scalar .procrustes_is_scalar .average_matrices

Documented in .consensus_target_procrustes_single .orthogonal_procrustes

# Procrustes rotation and the consensus-target engine: input validation, the closed-form
# orthogonal Procrustes solution, congruence/hyperplane diagnostics, and the single-start
# GPA-consensus iterator behind .gpa_consensus_target().

#' Average a list of matrices elementwise
#'
#' @param x List of conformable matrices.
#'
#' @returns A matrix with the same dimensions as the inputs.
#'
#' @noRd
.average_matrices <- function(x) {
  if (!is.list(x) || length(x) < 1L) {
    cli::cli_abort("{.arg x} must be a non-empty list of conformable matrices.",
                   class = "efa_not_matrix_list")
  }
  out <- Reduce(`+`, x) / length(x)
  dimnames(out) <- dimnames(x[[1L]])
  out
}


# Internal scalar checks for Procrustes routines
#
# These helpers intentionally use base R rather than checkmate. They are used
# by both the public wrappers and the internal consensus engine, so keeping the
# checks local gives clearer error messages and avoids partially entering the
# compiled optimizer with invalid control values.
.procrustes_is_scalar <- function(x) {
  is.numeric(x) && length(x) == 1L && !is.na(x) && is.finite(x)
}

.procrustes_check_numeric_scalar <- function(x, name, lower = -Inf, upper = Inf,
                                             lower_closed = TRUE,
                                             upper_closed = TRUE) {
  if (!.procrustes_is_scalar(x)) {
    cli::cli_abort("{.arg {name}} must be a finite numeric scalar.",
                   class = "efa_not_scalar")
  }

  lower_ok <- if (lower_closed) x >= lower else x > lower
  upper_ok <- if (upper_closed) x <= upper else x < upper

  if (!lower_ok || !upper_ok) {
    left <- if (lower_closed) "[" else "("
    right <- if (upper_closed) "]" else ")"
    cli::cli_abort("{.arg {name}} must be in {left}{lower}, {upper}{right}.",
                   class = "efa_out_of_range")
  }

  x
}

.procrustes_check_integer_scalar <- function(x, name, lower = -Inf, upper = Inf) {
  if (!.procrustes_is_scalar(x) || abs(x - round(x)) > sqrt(.Machine$double.eps)) {
    cli::cli_abort("{.arg {name}} must be a finite integer-like scalar.",
                   class = "efa_not_integer")
  }
  x <- as.integer(round(x))
  if (x < lower || x > upper) {
    cli::cli_abort("{.arg {name}} must be an integer in [{lower}, {upper}].",
                   class = "efa_out_of_range")
  }
  x
}

.procrustes_check_flag <- function(x, name) {
  if (!is.logical(x) || length(x) != 1L || is.na(x)) {
    cli::cli_abort("{.arg {name}} must be {.code TRUE} or {.code FALSE}.",
                   class = "efa_not_flag")
  }
  x
}

.procrustes_as_matrix <- function(x, name) {
  if (inherits(x, "LOADINGS")) {
    x <- unclass(x)
  }

  x <- as.matrix(x)
  if (!is.numeric(x)) {
    cli::cli_abort("{.arg {name}} must be a numeric matrix.", class = "efa_not_matrix")
  }
  storage.mode(x) <- "double"

  if (length(dim(x)) != 2L || any(dim(x) < 1L)) {
    cli::cli_abort("{.arg {name}} must have at least one row and one column.",
                   class = "efa_empty_matrix")
  }
  if (any(!is.finite(x))) {
    cli::cli_abort("{.arg {name}} must contain only finite values.",
                   class = "efa_nonfinite")
  }

  x
}

.procrustes_validate_matrix_pair <- function(A, B) {
  A <- .procrustes_as_matrix(A, "A")
  B <- .procrustes_as_matrix(B, "Target")

  if (!identical(dim(A), dim(B))) {
    cli::cli_abort("{.arg A} and {.arg Target} must have identical dimensions.",
                   class = "efa_dim_mismatch")
  }

  list(A = A, B = B)
}

.procrustes_validate_matrix_list <- function(x, name, min_length = 1L,
                                             expected_dim = NULL) {
  if (!is.list(x) || length(x) < min_length) {
    cli::cli_abort("{.arg {name}} must be a list with at least {min_length} matri{?x/ces}.",
                   class = "efa_not_matrix_list")
  }

  out <- lapply(seq_along(x), function(i) {
    .procrustes_as_matrix(x[[i]], paste0(name, "[[", i, "]]"))
  })

  ref_dim <- if (is.null(expected_dim)) dim(out[[1L]]) else expected_dim
  ok_dims <- vapply(out, function(z) identical(dim(z), ref_dim), logical(1L))
  if (!all(ok_dims)) {
    bad <- paste(which(!ok_dims), collapse = ", ")
    cli::cli_abort(
      c("All matrices in {.arg {name}} must have dimensions {paste(ref_dim, collapse = ' x ')}.",
        "x" = "Offending element(s): {bad}."),
      class = "efa_dim_mismatch"
    )
  }

  out
}

.procrustes_default_names <- function(n, prefix) {
  paste0(prefix, seq_len(n))
}

.procrustes_names <- function(A, B) {
  p <- nrow(A)
  k <- ncol(A)

  item_names <- rownames(B)
  if (is.null(item_names)) item_names <- rownames(A)
  if (is.null(item_names)) item_names <- .procrustes_default_names(p, "V")

  target_factor_names <- colnames(B)
  if (is.null(target_factor_names)) target_factor_names <- colnames(A)
  if (is.null(target_factor_names)) target_factor_names <- .procrustes_default_names(k, "F")

  source_factor_names <- colnames(A)
  if (is.null(source_factor_names)) source_factor_names <- target_factor_names

  list(
    items = item_names,
    source_factors = source_factor_names,
    target_factors = target_factor_names
  )
}

.procrustes_apply_dimnames <- function(out, A, B) {
  dn <- .procrustes_names(A, B)

  if (!is.null(out$loadings)) {
    dimnames(out$loadings) <- list(dn$items, dn$target_factors)
  }
  if (!is.null(out$T)) {
    dimnames(out$T) <- list(dn$source_factors, dn$target_factors)
  }
  if (!is.null(out$Phi)) {
    dimnames(out$Phi) <- list(dn$target_factors, dn$target_factors)
  }

  if (!is.null(out$all_values) && is.null(names(out$all_values))) {
    start_names <- if (!is.null(out$all_start_indices)) {
      paste0("start_", out$all_start_indices)
    } else {
      paste0("start_", seq_along(out$all_values))
    }
    names(out$all_values) <- start_names
  }
  if (!is.null(out$all_converged) && is.null(names(out$all_converged)) &&
      !is.null(names(out$all_values))) {
    names(out$all_converged) <- names(out$all_values)
  }
  if (!is.null(out$all_iterations) && is.null(names(out$all_iterations)) &&
      !is.null(names(out$all_values))) {
    names(out$all_iterations) <- names(out$all_values)
  }

  out
}

# Validate the optional cross-product shortcut against the matrix it stands for.
# `S` replaces `crossprod(A)` inside the compiled oblique objective and its analytic
# gradient, so anything else makes the solver minimize a different criterion while
# still reporting `convergence` and `valid`: the returned `value` then does not
# measure the returned loadings and is not even bounded below by zero. Checking
# costs the very O(p k^2) product the argument was meant to save, which is the
# right trade against an iterative solver that performs hundreds of k x k
# factorizations -- `S` survives as a compatibility argument, not an optimization.
.procrustes_validate_crossprod <- function(S, k, A) {
  S <- .procrustes_as_matrix(S, "S")
  if (!identical(dim(S), c(k, k))) {
    cli::cli_abort("{.arg S} must be a {k} x {k} matrix.", class = "efa_dim_mismatch")
  }
  SA <- crossprod(A)
  dev <- max(abs(S - SA))
  if (dev > 1e-8 * max(1, max(abs(SA)))) {
    cli::cli_abort(
      c("{.arg S} must be {.code crossprod(A)}.",
        "x" = "Largest absolute deviation from {.code crossprod(A)}: {signif(dev, 3)}.",
        "i" = "Omit {.arg S} to have it computed from {.arg A}."),
      class = "efa_bad_crossprod"
    )
  }
  # The quadratic form depends only on the symmetric part of S, and its analytic
  # gradient assumes that same matrix. `crossprod(A)` is symmetric and the check
  # above admits only deviations far below the solver's tolerance, so symmetrizing
  # cannot move the solution; divide before adding to avoid overflow near double.xmax.
  S / 2 + t(S) / 2
}

.procrustes_validate_t_init <- function(T_init, k) {
  T_init <- .procrustes_as_matrix(T_init, "T_init")
  if (!identical(dim(T_init), c(k, k))) {
    cli::cli_abort("{.arg T_init} must be a {k} x {k} matrix.", class = "efa_dim_mismatch")
  }
  if (any(sqrt(colSums(T_init * T_init)) <= sqrt(.Machine$double.eps))) {
    cli::cli_abort("{.arg T_init} must not contain zero or near-zero columns.",
                   class = "efa_zero_column")
  }
  if (qr(T_init)$rank < k) {
    cli::cli_abort("{.arg T_init} must be nonsingular.", class = "efa_singular")
  }
  T_init
}

.procrustes_validate_oblique_controls <- function(oblique_eps,
                                                  oblique_maxit,
                                                  oblique_max_line_search,
                                                  oblique_step0,
                                                  oblique_normalize,
                                                  oblique_random_starts,
                                                  oblique_screen_keep,
                                                  oblique_triage_maxit,
                                                  oblique_triage_improve_tol) {
  list(
    oblique_eps = .procrustes_check_numeric_scalar(
      oblique_eps, "oblique_eps", lower = 0, lower_closed = FALSE
    ),
    oblique_maxit = .procrustes_check_integer_scalar(
      oblique_maxit, "oblique_maxit", lower = 0L
    ),
    oblique_max_line_search = .procrustes_check_integer_scalar(
      oblique_max_line_search, "oblique_max_line_search", lower = 0L
    ),
    oblique_step0 = .procrustes_check_numeric_scalar(
      oblique_step0, "oblique_step0", lower = 0, lower_closed = FALSE
    ),
    oblique_normalize = .procrustes_check_flag(oblique_normalize, "oblique_normalize"),
    oblique_random_starts = .procrustes_check_integer_scalar(
      oblique_random_starts, "oblique_random_starts", lower = 0L
    ),
    oblique_screen_keep = .procrustes_check_integer_scalar(
      oblique_screen_keep, "oblique_screen_keep", lower = 0L
    ),
    oblique_triage_maxit = .procrustes_check_integer_scalar(
      oblique_triage_maxit, "oblique_triage_maxit", lower = 0L
    ),
    oblique_triage_improve_tol = .procrustes_check_numeric_scalar(
      oblique_triage_improve_tol, "oblique_triage_improve_tol", lower = 0
    )
  )
}

.procrustes_validate_start_index <- function(start, n_targets) {
  if (!.procrustes_is_scalar(start) || abs(start - round(start)) > sqrt(.Machine$double.eps)) {
    cli::cli_abort("{.arg start} must be either an integer index or a target matrix.",
                   class = "efa_bad_start")
  }
  start <- as.integer(round(start))
  if (start < 1L || start > n_targets) {
    cli::cli_abort("{.arg start} must be between 1 and {n_targets}.",
                   class = "efa_out_of_range")
  }
  start
}

.procrustes_validate_starts <- function(starts, n_targets) {
  if (is.null(starts)) {
    return(seq_len(n_targets))
  }
  if (!is.numeric(starts) || length(starts) < 1L || anyNA(starts) ||
      any(!is.finite(starts)) || any(abs(starts - round(starts)) > sqrt(.Machine$double.eps))) {
    cli::cli_abort("{.arg starts} must be a non-empty integer vector.",
                   class = "efa_bad_start")
  }
  starts <- as.integer(round(starts))
  if (any(starts < 1L | starts > n_targets)) {
    cli::cli_abort("All entries in {.arg starts} must be between 1 and {n_targets}.",
                   class = "efa_out_of_range")
  }
  if (anyDuplicated(starts)) {
    cli::cli_warn("Duplicate entries in {.arg starts} were removed.",
                  class = "efa_duplicate_starts")
    starts <- unique(starts)
  }
  starts
}

#' Count near-zero loadings
#'
#' Hyperplane count is the number of loadings with absolute value smaller than a
#' user-specified cutoff.
#'
#' @param L Numeric loading matrix.
#' @param cutoff Numeric scalar. Loadings with `abs(L) < cutoff` are counted as
#'   being in the hyperplane.
#'
#' @returns A list with the total hyperplane count and counts by factor and item.
#'
#' @noRd
.hyperplane_count <- function(L, cutoff = 0.15) {
  L <- .procrustes_as_matrix(L, "L")
  cutoff <- .procrustes_check_numeric_scalar(cutoff, "cutoff", lower = 0)

  in_hyperplane <- abs(L) < cutoff
  list(
    total = sum(in_hyperplane),
    by_factor = colSums(in_hyperplane),
    by_item = rowSums(in_hyperplane),
    cutoff = cutoff
  )
}

#' Mean squared discrepancy to a consensus target
#'
#' @param aligned_loadings List of aligned loading matrices.
#' @param target Consensus target matrix.
#'
#' @returns Mean sum of squared deviations from the target across matrices.
#'
#' @noRd
.consensus_loss <- function(aligned_loadings, target) {
  # Inputs are validated by the public/internal Procrustes entry points. Keeping
  # this inner-loop helper allocation-light matters for consensus and bootstrap
  # workflows where the loss is evaluated many times.
  mean(vapply(aligned_loadings, function(L) {
    sum((L - target)^2)
  }, numeric(1L)))
}


# Bare closed-form orthogonal Procrustes transform: the orthogonal `T` minimizing
# `||A %*% T - B||_F`. For warm starts where `A` and `B` are already validated;
# `.orthogonal_procrustes()` wraps this with input validation and full output.
.procrustes_orthogonal_T <- function(A, B) {
  s <- svd(crossprod(A, B))
  s$u %*% t(s$v)
}

#' Closed-form orthogonal Procrustes rotation
#'
#' Rotate `A` to the orthogonal target `B` by minimizing
#' `||A %*% T - B||_F^2` subject to `t(T) %*% T = I`.
#'
#' @param A Numeric matrix to be rotated.
#' @param B Numeric target matrix with the same dimensions as `A`.
#'
#' @returns A list with the rotated loadings, orthogonal transformation matrix,
#'   target criterion value, and basic diagnostics.
#'
#' @references
#' Schoenemann, P. H. (1966). A generalized solution of the orthogonal
#' Procrustes problem. *Psychometrika*, 31, 1-10.
#'
#' @keywords internal
.orthogonal_procrustes <- function(A, B) {
  mats <- .procrustes_validate_matrix_pair(A, B)
  A <- mats$A
  B <- mats$B

  Tmat <- .procrustes_orthogonal_T(A, B)

  Lrot <- A %*% Tmat
  value <- 0.5 * sum((Lrot - B)^2)

  out <- list(
    loadings = Lrot,
    T = Tmat,
    Phi = diag(ncol(A)),
    value = value,
    convergence = TRUE,
    # the closed-form orthogonal solution always exists; report the same `valid`
    # field the oblique path returns so callers can test the contract uniformly
    valid = TRUE,
    iterations = 0L,
    kappa_T = 1,
    Table = cbind(iter = 0, f = value, log10_s = NA_real_, step = NA_real_),
    method = "orthogonal_procrustes",
    best_start_index = 1L,
    all_start_indices = 1L,
    all_values = value,
    all_converged = TRUE,
    all_iterations = 0L,
    line_search_failed = FALSE
  )

  .procrustes_apply_dimnames(out, A, B)
}


#' Internal single-start GPA-consensus engine
#'
#' Performs a single GPA-consensus run from one starting target. The
#' multi-start wrapper [.gpa_consensus_target()] dispatches here.
#'
#' @inheritParams .gpa_consensus_target
#'
#' @keywords internal
.consensus_target_procrustes_single <- function(unrotated_list,
                                                init_targets = NULL,
                                                rotation = c("orthogonal", "oblique"),
                                                start = 1,
                                                tol = 1e-3,
                                                loss_tol = 1e-6,
                                                loss_patience = 5,
                                                convergence = c("either", "target", "loss", "both"),
                                                min_iter = 2,
                                                max_iter = 200,
                                                alpha = 1,
                                                match_target = TRUE,
                                                hyper_cutoff = 0.15,
                                                verbose = FALSE) {
  rotation <- match.arg(rotation)
  convergence <- match.arg(convergence)

  alpha <- .procrustes_check_numeric_scalar(alpha, "alpha", lower = 0, upper = 1,
                                            lower_closed = FALSE)
  tol <- .procrustes_check_numeric_scalar(tol, "tol", lower = 0, lower_closed = FALSE)
  if (!is.null(loss_tol)) {
    loss_tol <- .procrustes_check_numeric_scalar(loss_tol, "loss_tol", lower = 0,
                                                 lower_closed = FALSE)
  }
  loss_patience <- .procrustes_check_integer_scalar(loss_patience, "loss_patience", lower = 1L)
  min_iter <- .procrustes_check_integer_scalar(min_iter, "min_iter", lower = 0L)
  max_iter <- .procrustes_check_integer_scalar(max_iter, "max_iter", lower = 1L)
  if (min_iter > max_iter) {
    cli::cli_abort("{.arg min_iter} must not exceed {.arg max_iter}.",
                   class = "efa_out_of_range")
  }
  if (is.null(loss_tol) && convergence %in% c("loss", "both")) {
    cli::cli_abort("{.arg loss_tol} must not be {.code NULL} when {.arg convergence} is {.val loss} or {.val both}.",
                   class = "efa_bad_loss_tol")
  }

  match_target <- .procrustes_check_flag(match_target, "match_target")
  verbose <- .procrustes_check_flag(verbose, "verbose")
  hyper_cutoff <- .procrustes_check_numeric_scalar(hyper_cutoff, "hyper_cutoff", lower = 0)

  unrotated_list <- .procrustes_validate_matrix_list(
    unrotated_list, "unrotated_list", min_length = 2L
  )

  m <- length(unrotated_list)
  p <- nrow(unrotated_list[[1L]])
  k <- ncol(unrotated_list[[1L]])

  if (is.null(init_targets)) {
    init_targets <- unrotated_list
  } else {
    init_targets <- .procrustes_validate_matrix_list(
      init_targets, "init_targets", min_length = 1L, expected_dim = c(p, k)
    )
  }

  if (is.numeric(start) && length(start) == 1L) {
    start_index <- .procrustes_validate_start_index(start, length(init_targets))
    target <- init_targets[[start_index]]
    start_label <- start_index
  } else {
    target <- .procrustes_as_matrix(start, "start")
    if (!identical(dim(target), c(p, k))) {
      cli::cli_abort("If {.arg start} is a matrix, it must have the same dimensions as the loadings.",
                     class = "efa_dim_mismatch")
    }
    start_index <- NA_integer_
    start_label <- "matrix"
  }

  if (!is.na(start_index)) {
    dimnames(target) <- dimnames(init_targets[[start_index]])
  }
  if (is.null(rownames(target)) || is.null(colnames(target))) {
    dn <- .procrustes_names(unrotated_list[[1L]], target)
    dimnames(target) <- list(dn$items, dn$target_factors)
  }

  hist_iter <- integer(max_iter)
  hist_rel_change <- numeric(max_iter)
  hist_loss <- numeric(max_iter)
  hist_rel_loss_change <- rep(NA_real_, max_iter)
  hist_inner_mean_value <- numeric(max_iter)
  hist_inner_failures <- integer(max_iter)
  hist_inner_max_kappa <- numeric(max_iter)
  hist_loss_stable_count <- integer(max_iter)
  hist_target_ok <- logical(max_iter)
  hist_loss_ok <- logical(max_iter)
  hist_stop_rule_met <- logical(max_iter)

  converged <- FALSE
  convergence_reason <- NA_character_
  prev_loss <- NA_real_
  loss_stable_count <- 0L
  n_history <- 0L

  for (iter in seq_len(max_iter)) {
    aligned <- vector("list", m)

    for (d in seq_len(m)) {
      aligned[[d]] <- efa_procrustes(
        A = unrotated_list[[d]],
        Target = target,
        rotation = rotation
      )
    }

    aligned_loadings <- lapply(aligned, `[[`, "loadings")
    inner_values <- vapply(aligned, `[[`, numeric(1L), "value")
    inner_converged <- vapply(aligned, function(x) isTRUE(x$convergence), logical(1L))
    inner_kappa <- vapply(aligned, function(x) {
      if (is.null(x$kappa_T)) NA_real_ else x$kappa_T
    }, numeric(1L))

    centroid <- .average_matrices(aligned_loadings)
    if (isTRUE(match_target) && k > 1L) {
      centroid <- .align_solution(L = centroid, L_target = target)$loadings
    }

    new_target <- (1 - alpha) * target + alpha * centroid
    dimnames(new_target) <- dimnames(target)

    rel_change <- norm(new_target - target, type = "F") /
      (norm(target, type = "F") + 1e-12)

    # Per-iteration loss proxy: the slices were aligned to the previous target, so
    # this scores them against the freshly updated target rather than re-aligning
    # to it. It drives the loss-stabilisation stopping rule and the history; once
    # the target has stabilised the proxy and the exact GPA loss coincide.
    current_loss <- .consensus_loss(aligned_loadings, new_target)

    rel_loss_change <- if (is.na(prev_loss)) {
      NA_real_
    } else {
      abs(prev_loss - current_loss) / (abs(prev_loss) + 1e-12)
    }

    if (!is.null(loss_tol) && !is.na(rel_loss_change) && rel_loss_change < loss_tol) {
      loss_stable_count <- loss_stable_count + 1L
    } else {
      loss_stable_count <- 0L
    }

    target_ok <- rel_change < tol
    loss_ok <- !is.null(loss_tol) && loss_stable_count >= loss_patience

    stop_rule_met <- switch(
      convergence,
      target = target_ok,
      loss = loss_ok,
      either = target_ok || loss_ok,
      both = target_ok && loss_ok
    )

    n_history <- iter
    hist_iter[iter] <- iter
    hist_rel_change[iter] <- rel_change
    hist_loss[iter] <- current_loss
    hist_rel_loss_change[iter] <- rel_loss_change
    hist_inner_mean_value[iter] <- mean(inner_values)
    hist_inner_failures[iter] <- sum(!inner_converged)
    hist_inner_max_kappa[iter] <- if (all(is.na(inner_kappa))) NA_real_ else max(inner_kappa, na.rm = TRUE)
    hist_loss_stable_count[iter] <- loss_stable_count
    hist_target_ok[iter] <- target_ok
    hist_loss_ok[iter] <- loss_ok
    hist_stop_rule_met[iter] <- stop_rule_met

    if (verbose) {
      cli::cli_inform(sprintf(
        paste0(
          "iter %d | rel_change = %.3e | loss = %.8f | ",
          "rel_loss_change = %s | stable = %d | inner_failures = %d"
        ),
        iter,
        rel_change,
        current_loss,
        ifelse(is.na(rel_loss_change), "NA", sprintf("%.3e", rel_loss_change)),
        loss_stable_count,
        sum(!inner_converged)
      ))
    }

    target <- new_target
    prev_loss <- current_loss

    if (iter >= min_iter && isTRUE(stop_rule_met)) {
      converged <- TRUE
      convergence_reason <- if (target_ok && loss_ok) {
        "target_and_loss"
      } else if (target_ok) {
        "target"
      } else if (loss_ok) {
        "loss"
      } else {
        convergence
      }
      break
    }
  }

  keep <- seq_len(n_history)
  history <- data.frame(
    iter = hist_iter[keep],
    rel_change = hist_rel_change[keep],
    loss = hist_loss[keep],
    rel_loss_change = hist_rel_loss_change[keep],
    inner_mean_value = hist_inner_mean_value[keep],
    inner_failures = hist_inner_failures[keep],
    inner_max_kappa = hist_inner_max_kappa[keep],
    loss_stable_count = hist_loss_stable_count[keep],
    target_ok = hist_target_ok[keep],
    loss_ok = hist_loss_ok[keep],
    stop_rule_met = hist_stop_rule_met[keep]
  )

  final_aligned <- vector("list", m)
  for (d in seq_len(m)) {
    final_aligned[[d]] <- efa_procrustes(
      A = unrotated_list[[d]],
      Target = target,
      rotation = rotation
    )
  }

  final_aligned_loadings <- lapply(final_aligned, `[[`, "loadings")
  final_T <- lapply(final_aligned, `[[`, "T")
  final_Phi <- lapply(final_aligned, `[[`, "Phi")
  final_values <- vapply(final_aligned, `[[`, numeric(1L), "value")
  final_converged <- vapply(final_aligned, function(x) isTRUE(x$convergence), logical(1L))
  final_kappa <- vapply(final_aligned, function(x) {
    if (is.null(x$kappa_T)) NA_real_ else x$kappa_T
  }, numeric(1L))

  pooled_loadings <- .average_matrices(final_aligned_loadings)
  dimnames(pooled_loadings) <- dimnames(target)

  pooled_phi <- if (rotation == "orthogonal" || k == 1L) {
    diag(k)
  } else {
    .average_matrices(final_Phi)
  }
  pooled_phi <- (pooled_phi + t(pooled_phi)) / 2
  diag(pooled_phi) <- 1
  dimnames(pooled_phi) <- list(colnames(target), colnames(target))

  final_loss <- .consensus_loss(final_aligned_loadings, target)

  outer_converged <- converged
  final_inner_converged <- all(final_converged)
  overall_converged <- outer_converged && final_inner_converged
  reported_convergence_reason <- if (overall_converged) {
    convergence_reason
  } else if (!outer_converged) {
    convergence_reason
  } else {
    "final_inner_alignment"
  }

  list(
    converged = overall_converged,
    outer_converged = outer_converged,
    final_inner_converged = final_inner_converged,
    convergence_reason = reported_convergence_reason,
    outer_convergence_reason = convergence_reason,
    iterations = nrow(history),
    tol = tol,
    loss_tol = loss_tol,
    loss_patience = loss_patience,
    convergence = convergence,
    min_iter = min_iter,
    alpha = alpha,
    match_target = match_target,
    rotation = rotation,
    start = start_label,
    start_index = start_index,
    target = target,
    history = history,
    aligned_loadings = final_aligned_loadings,
    aligned_phi = final_Phi,
    transformations = final_T,
    pooled_loadings = pooled_loadings,
    pooled_phi = pooled_phi,
    mean_loss = final_loss,
    final_values = final_values,
    final_converged = final_converged,
    final_failures = which(!final_converged),
    final_kappa_T = final_kappa,
    hyperplane_target = .hyperplane_count(target, cutoff = hyper_cutoff),
    hyperplane_pooled = .hyperplane_count(pooled_loadings, cutoff = hyper_cutoff)
  )
}

Try the EFAtools package in your browser

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

EFAtools documentation built on Aug. 21, 2026, 5:16 p.m.