Nothing
#' Bass-ackwards hierarchical structural analysis
#'
#' Extracts factor/component solutions at levels 1 through `k`, then
#' characterises the hierarchy by computing between-level factor-score
#' correlations. The "hierarchy" is descriptive: edges are score correlations,
#' not a fitted higher-order SEM.
#'
#' @section Defaults and why:
#' * **`engine = "pca"`** -- the original Goldberg (2006) method; fastest; never
#' fails to converge; the Waller (2007) algebra is exact for components.
#' * **`rotation = "varimax"`** -- the `T'=T^-1` property of orthogonal
#' rotation enables the closed-form `W'RW` edge algebra and keeps
#' within-level factors uncorrelated so cross-level edges reflect only the
#' hierarchical signal. Matches Goldberg (2006), Kim & Eaton (2015), and
#' Forbush et al. (2024). Varimax is the only supported rotation; oblique
#' rotation would confound the between-level signal that is the method's
#' core output.
#' * **`cor = "pearson"`** -- no silent basis switching. If your items look
#' ordinal (<= 7 distinct integer values), a cli warning will suggest
#' `cor = "polychoric"`, which is available for all three engines.
#' * **`align_signs = TRUE`** -- unaligned signs make the output unreadable.
#' Anchor: m1f1 is oriented toward the positive manifold; each subsequent
#' factor is flipped so its edge to its primary parent is positive.
#' * **`keep_scores = FALSE` / `keep_fits = FALSE`** -- memory and privacy.
#' Scores are O(n x Sigmak) and often sensitive; raw engine fits can be large.
#' Both are recomputable from the stored `r` matrix.
#'
#' @param data A data frame or numeric matrix of observed variables (items in
#' columns, observations in rows). Alternatively, a pre-computed **correlation
#' matrix** may be supplied (a square, symmetric, numeric matrix with unit
#' diagonal). When a correlation matrix is supplied, `engine` must be `"pca"`
#' or `"efa"` (ESEM requires raw data), and the `missing` and `cor` arguments
#' are ignored. See the *Correlation-matrix input* section below.
#' @param k_max Maximum number of factors/components to extract. Required; use
#' [suggest_k()] if uncertain. Sets the *depth* of the hierarchy: levels
#' 1 through `k_max` are all extracted and retained. (Note: `suggest_k()`'s
#' `k_max` means something related but distinct -- the max number of
#' factors/components it *evaluates* when recommending a depth, not a depth
#' itself; see that function's docs.)
#' @param n_obs Number of observations, or (on the raw-data FIML path) a string
#' selecting which N feeds the fit indices.
#'
#' * **Correlation-matrix input:** a positive integer. Required for
#' `engine = "efa"` ([psych::fa()] needs N for chi-square / RMSEA / TLI);
#' optional for `"pca"` (stored as `NA_integer_` if omitted). For PCA it
#' is recorded in the result metadata and feeds the N-based
#' sampling-adequacy checks only -- PCA level fit is eigenvalue-based and
#' computes no N-dependent fit statistics.
#' * **Raw data:** N is normally taken from `nrow(data)` and a numeric `n_obs`
#' is ignored (with a warning). The exception is `missing = "fiml"` with
#' `engine = "pca"`/`"efa"` (M38): because [psych::corFiml()] estimates the
#' correlation matrix from incomplete rows, `n_obs` may be `"total"`
#' (default -- every row contributing to the FIML likelihood, matching the
#' FIML convention; Enders, 2010) or `"complete"` (complete-case N, a
#' conservative lower bound). Point estimates (loadings, edges) do not
#' depend on this choice; only the EFA fit indices do, and those are
#' *approximate* under this two-step (FIML matrix into normal-theory EFA)
#' route regardless of N (Zhang & Savalei, 2020). A string `n_obs` is
#' accepted only on this path.
#' @param engine Extraction engine: `"pca"` (default), `"efa"`, or `"esem"`.
#' `"esem"` uses [lavaan::efa()] with rotation-aware SEs and per-level fit
#' indices; recommended for the clinical/HiTOP workflow (Kim & Eaton, 2015;
#' Forbush et al., 2024). Requires lavaan >= 0.6-13.
#' @param fm Factor extraction method passed to [psych::fa()]; only used when
#' `engine = "efa"`. One of `"minres"` (default, robust OLS), `"ml"`
#' (maximum likelihood, gives chi-square fit but converges less reliably at
#' deep levels), or `"pa"` (principal axis). Ignored for `engine = "pca"`.
#' @param cor Correlation basis: `"pearson"` (default), `"spearman"`, or
#' `"polychoric"`. For PCA/EFA, `"polychoric"` computes a polychoric
#' correlation matrix via `psych::polychoric()` (requires psych). For ESEM,
#' it triggers WLSMV estimation via lavaan. A cli warning is emitted when
#' ordinal-looking columns are detected and `cor` is not `"polychoric"`.
#' @param estimator Estimation method for the ESEM engine. `NULL` (default)
#' auto-selects: `"WLSMV"` when `cor = "polychoric"`, `"ML"` otherwise.
#' Pass explicitly to override: `"ULSMV"` (unweighted WLS), `"MLR"`
#' (robust ML). `cor = "polychoric"` with `estimator = "ML"`/`"MLR"` errors
#' (lavaan itself does not support ML/MLR on ordered indicators); `"WLSMV"`/
#' `"ULSMV"` with a continuous `cor` is allowed (a valid, if atypical,
#' continuous WLS/ADF estimator). Ignored for PCA and EFA engines. The
#' effective value (after auto-selection) is recorded in `x$meta$estimator`
#' (`NA` for PCA/EFA).
#' @param missing How to handle missing item responses. One of:
#' * `"pairwise"` (default) -- use all available observations pairwise.
#' For PCA/EFA this feeds `stats::cor(use = "pairwise.complete.obs")`.
#' For ESEM with WLSMV/ULSMV (ordinal), lavaan uses `available.cases`,
#' which computes polychoric thresholds and correlations from all rows that
#' contribute to each pair -- MCAR-valid and uses the full N.
#' For ESEM with ML/MLR (continuous), lavaan uses listwise deletion
#' internally while edges are computed from a pairwise correlation matrix;
#' this minor inconsistency is documented in `$meta`. A warning is emitted
#' when incomplete rows are detected.
#' * `"listwise"` -- only complete rows are used. Reduces data to
#' `stats::complete.cases()` before fitting, so the correlation matrix,
#' the engine fit, and the edges are all consistent. `n_obs` in the result
#' reflects the reduced N.
#' * `"fiml"` -- Full Information Maximum Likelihood. For `engine = "esem"`
#' (with `estimator = "ML"`/`"MLR"`), passes `missing = "fiml"` to
#' [lavaan::efa()] and derives edge correlations from lavaan's
#' FIML-estimated saturated model. For `engine = "pca"`/`"efa"` (M38), the
#' correlation matrix is estimated via [psych::corFiml()] and fed to the
#' usual `W'RW` algebra; this requires `cor = "pearson"` (corFiml estimates
#' a multivariate-normal matrix) and the route is announced via a cli
#' message. Errors for WLSMV/ULSMV and for a non-Pearson PCA/EFA basis.
#' Note: FIML improves estimation under missingness but does not impute item
#' responses; score materialisation (`keep_scores = TRUE`) still produces
#' `NA` rows for incomplete observations. See `n_obs` for the fit-index
#' sample size on the PCA/EFA path.
#' @param align_signs Logical; sign-align factors to primary-parent lineage?
#' Default `TRUE`.
#' @param keep_scores Logical; store factor scores in the result? Default
#' `FALSE` (recomputable via [augment.ackwards()]). When `TRUE`,
#' per-observation scores are stored in `x$scores` as a named list of
#' `n x k_j` matrices, one per level, standardized by real score SDs
#' (see [augment.ackwards()]).
#' @param keep_fits Logical; store raw engine fit objects? Default `FALSE`.
#' When `TRUE`, the per-level fit objects (psych or lavaan) are stored in
#' `x$fits` as a named list indexed by level.
#' @param seed Integer seed for stochastic engines (not used by PCA but
#' captured for reproducibility metadata). Default `NULL`.
#' @param pairs Which level pairs to compute edges for: `"adjacent"` (default,
#' classic Goldberg -- only consecutive levels) or `"all"` (Forbes extension --
#' every pair of levels, including skip-level correlations). [prune()]
#' recomputes its own all-pairs edges on demand regardless of this setting,
#' so pruning does not require `pairs = "all"` here.
#' @param cut_show Edges with `|r| >= cut_show` are flagged `above_cut` in
#' `tidy()` output. Default `0.3`.
#' @param correct Continuity correction passed to [psych::polychoric()] on the
#' PCA/EFA polychoric path (`engine = "pca"`/`"efa"` with
#' `cor = "polychoric"`). Default `0.5` (psych's own default), which adds that
#' value to zero cells before estimating thresholds. **Set `correct = 0`** if
#' `psych::polychoric()` fails on your data (its error suggests exactly this) --
#' this typically happens when an item has a near-empty response category or
#' when items with unequal category counts produce a sparse cross-cell. Ignored
#' on other paths: ESEM computes its own polychoric correlations inside lavaan,
#' and the Pearson/Spearman bases do not use it.
#' @param ... Reserved for future arguments.
#'
#' @return An object of class `"ackwards"`. See [print.ackwards()],
#' [tidy.ackwards()], [glance.ackwards()], and [augment.ackwards()] for
#' output methods.
#'
#' @seealso [print.ackwards()], [tidy.ackwards()], [glance.ackwards()],
#' [prune()] (Forbes-extension redundancy/artifact flagging, piped off the
#' result of this function)
#'
#' @references
#' Goldberg, L. R. (2006). Doing it all Bass-Ackwards: The development of
#' hierarchical factor structures from the top down. *Journal of Research in
#' Personality*, 40(4), 347--358. \doi{10.1016/j.jrp.2006.01.001}
#'
#' Waller, N. G. (2007). A general method for computing hierarchical component
#' structures by Goldberg's bass-ackwards method. *Journal of Research in
#' Personality*, 41(4), 745--752. \doi{10.1016/j.jrp.2006.08.005}
#'
#' Forbes, M. K. (2023). Improving hierarchical models of individual
#' differences: An extension of Goldberg's bass-ackward method.
#' *Psychological Methods*. \doi{10.1037/met0000546}
#'
#' Enders, C. K. (2010). *Applied Missing Data Analysis*. Guilford Press.
#'
#' Zhang, X., & Savalei, V. (2020). Examining the effect of missing data on
#' RMSEA and CFI under normal theory full-information maximum likelihood.
#' *Structural Equation Modeling*, 27(2), 219--239.
#' \doi{10.1080/10705511.2019.1642111}
#'
#' @section Performance (ESEM, large item sets):
#' The ESEM engine fits a separate `lavaan` model at every level 1..`k_max`.
#' For ordinal data (`cor = "polychoric"`, WLSMV) the costly sample statistics
#' lavaan derives from the raw data -- thresholds, the polychoric correlation
#' matrix, and the asymptotic weight matrix -- depend only on the data, not on
#' the number of factors, so they are **computed once** at the first level and
#' **reused** for every deeper level (identical solutions, much less work). This
#' matters most when you have many items (hundreds), where recomputing those
#' statistics at each level dominated the run time.
#'
#' The per-level model fits are mutually independent and are dispatched through
#' the \pkg{future} framework when \pkg{future.apply} is installed. By default
#' the plan is sequential (no behaviour change). To run the levels in parallel,
#' set a plan once before calling `ackwards()`:
#' \preformatted{
#' future::plan(future::multisession, workers = 4) # or multicore on Unix
#' x <- ackwards(items, k_max = 8, engine = "esem", cor = "polychoric")
#' }
#' Parallelism pays off when the per-level fits are heavy (large `p`, several
#' levels); for small problems the worker startup cost can outweigh it. Results
#' are reproducible across plans when `seed` is supplied. PCA and EFA already
#' compute their correlation matrix once and are unaffected.
#'
#' @section Correlation-matrix input:
#' When `data` is a pre-computed correlation matrix (square, symmetric, unit
#' diagonal), `ackwards()` runs entirely from that matrix using the `W'RW`
#' algebra -- no raw item responses are needed. This is useful when you have
#' a published correlation table or a polychoric matrix computed externally.
#'
#' Constraints and behaviour when a correlation matrix is supplied:
#' * **Engine:** only `"pca"` and `"efa"` are supported. `"esem"` requires raw
#' data (for lavaan's own polychoric computation, WLSMV estimation, and
#' per-level fit indices) and will error clearly.
#' * **`n_obs`:** required for `"efa"` (psych needs N for chi-square / RMSEA /
#' TLI); optional for `"pca"` (stored as `NA` if omitted; used for the
#' N-based sampling-adequacy checks and result metadata only -- PCA computes
#' no N-dependent fit statistics).
#' * **`cor` argument:** ignored -- the basis is already determined by the
#' matrix you supply. A warning is emitted if you set `cor` explicitly.
#' * **`missing` argument:** ignored -- missingness was handled when computing
#' the matrix. A warning is emitted if you set `missing` explicitly.
#' * **Factor scores:** `keep_scores = TRUE` will error; `augment()` and
#' `tidy(what = "scores")` will also error because individual-level scores
#' require row-level item responses.
#' * **`$cor` field:** stored as `NA_character_`; printed as
#' `"(user-supplied matrix)"`.
#'
#' @section When to trust the result:
#' `ackwards()` raises diagnostics as it fits. They fall into three tiers by
#' what they mean for whether you should trust and report the solution:
#'
#' **Fatal -- fix before trusting.** The result is undefined or rests on a
#' broken correlation matrix:
#' * A **constant item** (no variance) errors -- drop it (see [check_items()]).
#' * **`psych::polychoric()` fails** -- usually a near-empty response category;
#' set `correct = 0` or collapse rare categories.
#' * A **level fails to converge** -- the hierarchy is truncated to the deepest
#' level that did; do not interpret beyond it.
#' * A **near-singular correlation matrix** (smallest eigenvalue `< 1e-4`,
#' recorded in `meta$near_singular` / `meta$min_eigenvalue` and re-surfaced by
#' `print()`/`summary()`) means per-level fit indices and factor scores are
#' unreliable and the loadings/edges rest on a rank-deficient matrix. (For EFA
#' the residual-based fallback inflates `TLI`/`RMSEA`; for ESEM `CFI` comes
#' back `NA`.) Trim redundant items, use `missing = "listwise"`, or -- on the
#' polychoric basis -- collapse sparse categories or set `correct = 0`.
#'
#' **Caution -- interpret carefully.** The solution exists but may be unstable:
#' * A **Heywood case** (communality `> 1` / negative uniqueness) at a level --
#' check that level's loadings make substantive sense; consider fewer factors.
#' * A **near-constant item** (one response category dominates) can drive a
#' meaningless factor -- inspect it with [check_items()].
#' * **Ordinal data on a Pearson basis** attenuates correlations -- fine for a
#' quick look or `suggest_k()` screening, but report the final model on
#' `cor = "polychoric"`.
#'
#' **Informational -- usually fine.** Proceed, just be aware: the
#' pairwise-missing note, a merely *sparse* (rare-but-present) response
#' category, and the ordinal-detection warning when you did intend Pearson.
#'
#' @examples
#' # sim16 is continuous with a known 1 -> 2 -> 4 hierarchy, so the default
#' # pearson basis is appropriate. For ordinal items (e.g. bfi25), fit on the
#' # polychoric basis instead -- see `cor = "polychoric"` and the ordinal
#' # vignette.
#' x <- ackwards(sim16, k_max = 4)
#' print(x)
#' tidy(x)
#' glance(x)
#'
#' # Correlation-matrix input (PCA engine; n_obs optional)
#' R <- cor(sim16)
#' x_R <- ackwards(R, k_max = 4)
#' print(x_R)
#'
#' @export
ackwards <- function(
data,
k_max,
engine = "pca",
cor = "pearson",
fm = "minres",
estimator = NULL,
missing = "pairwise",
n_obs = NULL,
align_signs = TRUE,
keep_scores = FALSE,
keep_fits = FALSE,
seed = NULL,
pairs = "adjacent",
cut_show = 0.3,
correct = 0.5,
...
) {
cl <- match.call()
# M34: pruning moved to a standalone prune() verb; the five args below no
# longer exist here. Without this guard they would be silently absorbed by
# `...` (a masked-argument footgun, not a clean break) -- error loudly
# instead (Invariant 6).
dots <- list(...)
moved_args <- intersect(
names(dots),
c("prune", "redundancy_r", "redundancy_phi", "min_items", "orphan_r")
)
if (length(moved_args) > 0L) {
cli::cli_abort(c(
"!" = "{.arg {moved_args}} {?is/are} no longer {?an argument/arguments} \\
to {.fn ackwards}.",
"i" = "Pruning moved to a standalone verb (M34): use \\
{.code ackwards(...) |> prune(...)} instead."
))
}
# Any other stray argument (typically a typo like `kmax = 6`) errors too --
# silently running with the default instead would be the same masked-argument
# footgun the moved-args guard exists for.
.check_unknown_dots(dots, "ackwards")
# --- Detect correlation-matrix vs. raw-data input ---------------------------
# Guard first: a square symmetric matrix with non-unit diagonal is almost
# certainly a covariance matrix -- give a targeted error before it silently
# routes to the raw-data branch and produces a confusing message.
if (is.matrix(data)) .check_maybe_cov_matrix(data)
input_type <- if (.is_cor_matrix(data)) "cor_matrix" else "data"
# Effective ESEM estimator, resolved in BRANCH B below; stays NULL for
# PCA/EFA and for BRANCH A (cor_matrix input, which errors for engine =
# "esem" before reaching this point). Recorded in $meta$estimator (M32).
estimator_eff <- NULL
# Variable labels (M36): captured from a data.frame's per-column "label"
# attribute (the attribute labelled/haven write) before as.matrix() strips it,
# so top_items() can display "How much..." instead of the bare colname. NULL
# for matrix / correlation-matrix input, or when no column carries a label.
item_labels <- .capture_item_labels(data)
# --- Shared argument validation ---------------------------------------------
engine <- rlang::arg_match(engine, c("pca", "efa", "esem"))
fm <- rlang::arg_match(fm, c("minres", "ml", "pa"))
pairs <- rlang::arg_match(pairs, c("adjacent", "all"))
if (!is.numeric(k_max) || length(k_max) != 1L || k_max < 2L || k_max != as.integer(k_max)) {
cli::cli_abort("{.arg k_max} must be an integer >= 2 (need at least two levels for a hierarchy).")
}
k_max <- as.integer(k_max)
if (!is.numeric(cut_show) || length(cut_show) != 1L || is.na(cut_show) ||
cut_show < 0 || cut_show > 1) {
cli::cli_abort(
"{.arg cut_show} must be a single number in [0, 1] (it thresholds |r|)."
)
}
if (!is.numeric(correct) || length(correct) != 1L || is.na(correct) ||
correct < 0) {
cli::cli_abort(
"{.arg correct} must be a single non-negative number (the polychoric \\
continuity correction passed to {.fn psych::polychoric})."
)
}
# Smallest eigenvalue of the correlation matrix, recorded in $meta as a durable
# near-singularity signal. Set on the raw-data polychoric path (where it bites);
# stays NA elsewhere (cor-matrix input, ESEM, well-behaved Pearson/Spearman).
min_eigenvalue <- NA_real_
# ============================================================
# BRANCH A -- correlation-matrix input
# ============================================================
if (input_type == "cor_matrix") {
# Gate ESEM: lavaan must fit on raw data
if (engine == "esem") {
cli::cli_abort(c(
"!" = "{.code engine = \"esem\"} requires raw item data.",
"i" = "ESEM (lavaan) needs individual responses for WLSMV estimation, \\
polychoric computation, and per-level fit indices.",
"i" = "Use {.code engine = \"pca\"} or {.code engine = \"efa\"} \\
when supplying a correlation matrix."
))
}
# Warn if cor/missing are set explicitly (they're ignored for R input).
cor_was_set <- !missing(cor) && cor != "pearson"
if (cor_was_set) {
cli::cli_warn(
c(
"!" = "{.arg cor} is ignored when a correlation matrix is supplied \\
(the basis is already determined by your matrix).",
"i" = "Remove {.arg cor} from your call to suppress this warning."
),
.frequency = "once",
.frequency_id = "ackwards_cor_ignored"
)
}
# missing() is a base R function that tests whether the caller supplied the
# argument; it coincidentally has the same name as the formal `missing`.
missing_was_set <- !missing(missing) && missing != "pairwise"
if (missing_was_set) {
cli::cli_warn(
c(
"!" = "{.arg missing} is ignored when a correlation matrix is supplied \\
(missingness was handled before computing the matrix).",
"i" = "Remove {.arg missing} from your call to suppress this warning."
),
.frequency = "once",
.frequency_id = "ackwards_missing_ignored"
)
}
# n_obs: required for EFA, optional for PCA. Numeric-only here (the
# correlation-matrix branch); routed through the shared count check so the
# is.na() guard is present (twin of the suggest_k n_obs = NA drift bug).
if (!is.null(n_obs)) n_obs <- .check_count(n_obs, "n_obs")
if (engine == "efa" && is.null(n_obs)) {
cli::cli_abort(c(
"!" = "{.arg n_obs} is required when {.code engine = \"efa\"} and \\
a correlation matrix is supplied.",
"i" = "{.fn psych::fa} needs N to compute chi-square / RMSEA / TLI.",
"i" = "Supply the number of observations: {.code n_obs = <N>}."
))
}
# Block keep_scores=TRUE: no row-level data to score
if (isTRUE(keep_scores)) {
cli::cli_abort(c(
"!" = "{.code keep_scores = TRUE} requires raw item data.",
"i" = "Factor scores are individual-level (n x k) projections and \\
cannot be computed from a correlation matrix alone.",
"i" = "Either supply raw data or set {.code keep_scores = FALSE}."
))
}
# Validate and normalise R; synthesise dimnames if absent
validated <- .validate_cor_matrix(as.matrix(data))
R <- validated$R
p <- nrow(R)
# A user-supplied matrix can be near-singular too (records $meta$near_singular
# + warns once; cor is NA here, so the generic remedy advice is used).
# Reuses the eigenvalue .validate_cor_matrix() already computed (M60).
min_eigenvalue <- .near_singular_check(R, cor, min_eig = validated$min_eig)
if (k_max > p) {
cli::cli_abort(
"{.arg k_max} ({k_max}) cannot exceed the number of variables ({p})."
)
}
# Store effective n_obs (NA when not supplied for PCA)
n_obs_eff <- if (is.null(n_obs)) NA_integer_ else n_obs
if (engine == "pca" && is.null(n_obs)) {
cli::cli_inform(c(
"i" = "{.arg n_obs} not supplied; stored as {.code NA}.",
"i" = "PCA level fit is eigenvalue-based and does not use N; \\
supplying {.code n_obs = <N>} records it in the result \\
metadata and enables the N-based sampling-adequacy checks."
))
}
cor_eff <- NA_character_
missing_eff <- NA_character_
n_complete <- NA_integer_
is_ordinal <- FALSE
if (!is.null(seed)) set.seed(seed)
engine_out <- switch(engine,
pca = pca_levels(R,
k_max = k_max, cor = "pearson",
keep_fits = keep_fits
),
efa = efa_levels(R,
k_max = k_max, fm = fm, n_obs = n_obs_eff,
cor = "pearson", keep_fits = keep_fits
)
)
levels_list <- engine_out$levels
fits_stored <- engine_out$fits
data_mat <- NULL
} else {
# ============================================================
# BRANCH B -- raw data input (existing behaviour)
# ============================================================
# n_obs on the raw-data path is resolved after `missing`/`engine` below
# (its meaning depends on them); see the n_obs_mode block.
cor <- rlang::arg_match(cor, c("pearson", "spearman", "polychoric"))
if (!is.null(estimator)) {
estimator <- rlang::arg_match(estimator, c("ML", "MLR", "WLSMV", "ULSMV"))
}
missing <- rlang::arg_match(missing, c("pairwise", "listwise", "fiml"))
# --- n_obs on the raw-data path (M38) ------------------------------------
# Numeric n_obs is for correlation-matrix input only; with raw data N comes
# from the data. Under missing = "fiml" (pca/efa) a *string* n_obs selects
# which N feeds the fit indices: "total" (all rows contributing to the FIML
# likelihood -- the FIML convention, Enders 2010) or "complete" (complete
# cases; a conservative lower bound). Default "total".
n_obs_mode <- "total"
fiml_pcaefa <- missing == "fiml" && engine %in% c("pca", "efa")
if (!is.null(n_obs)) {
if (is.character(n_obs)) {
n_obs <- rlang::arg_match(n_obs, c("total", "complete"))
if (!fiml_pcaefa) {
cli::cli_abort(c(
"!" = "{.code n_obs = \"{n_obs}\"} is only valid with \\
{.code missing = \"fiml\"} and {.code engine = \"pca\"} or \\
{.code \"efa\"}.",
"i" = "For correlation-matrix input pass a numeric N; with raw data \\
and no FIML, N is taken from {.code nrow(data)}."
))
}
n_obs_mode <- n_obs
} else {
cli::cli_warn(
c(
"!" = "{.arg n_obs} is ignored when raw data are supplied \\
(N is determined from {.code nrow(data)}).",
if (fiml_pcaefa) {
c("i" = "Under {.code missing = \"fiml\"}, pass \\
{.code n_obs = \"total\"} or {.code \"complete\"} to \\
choose which N feeds the fit indices.")
} else {
c("i" = "Remove {.arg n_obs} from your call to suppress this warning.")
}
),
.frequency = "once",
.frequency_id = "ackwards_nobs_ignored"
)
}
}
# cor = "polychoric" marks every item `ordered` for lavaan; ML/MLR flatly
# do not support ordered indicators (lavaan itself errors with "estimator
# ML for ordered data is not supported yet"). Without this guard the
# failure surfaces many calls deep as a per-level ESEM warning, then a
# generic "failed to build at least 2 converged levels" abort that
# misdiagnoses the cause as multicollinearity (Invariant 6: loud, not
# buried). WLSMV/ULSMV with a continuous `cor` is left alone -- lavaan
# runs it as a valid (if atypical) continuous WLS/ADF estimator.
if (engine == "esem" && cor == "polychoric" && !is.null(estimator) &&
estimator %in% c("ML", "MLR")) {
cli::cli_abort(c(
"!" = "{.code cor = \"polychoric\"} is incompatible with \\
{.code estimator = \"{estimator}\"}.",
"i" = "Polychoric correlations require ordinal (WLS-family) estimation; \\
{.val {estimator}} assumes continuous indicators and lavaan \\
itself errors on the combination.",
"i" = "Use {.code estimator = \"WLSMV\"} (default) or \\
{.code \"ULSMV\"}, or switch to {.code cor = \"pearson\"} \\
to use {.val {estimator}}."
))
}
# Resolve effective estimator early (needed for missing= validation before
# the engine dispatch block; also avoids repeating the logic below).
estimator_eff <- if (engine == "esem") {
if (!is.null(estimator)) {
estimator
} else if (cor == "polychoric") {
"WLSMV"
} else {
"ML"
}
} else {
NULL
}
.resolve_missing(missing, engine, estimator_eff, cor)
# cor = "spearman" + engine = "esem" is semantically inconsistent: lavaan fits
# Pearson ML on raw data while compute_edges() uses Spearman R for scoring
# (DESIGN.md s.14 known limitations). Warn loudly rather than silently mixing bases.
if (engine == "esem" && cor == "spearman") {
cli::cli_warn(
c(
"!" = "{.code cor = \"spearman\"} with {.code engine = \"esem\"} \\
uses inconsistent bases: lavaan fits a Pearson-ML model on raw \\
data while edges are computed from the Spearman correlation matrix.",
"i" = "Consider {.code cor = \"polychoric\"} for ordinal data or \\
{.code cor = \"pearson\"} for a consistent continuous path."
),
.frequency = "once",
.frequency_id = "ackwards_esem_spearman"
)
}
data_mat <- .as_numeric_matrix(data)
# --- Item screen (shared with check_items()) -----------------------------
# A constant item breaks every basis (and psych::polychoric() silently
# deletes it, corrupting the loadings-to-item mapping downstream); a
# near-degenerate item can yield a plausible but meaningless factor. Catch
# both here -- naming the offenders -- before any correlation is computed.
scr <- .screen_items(as.data.frame(data_mat), cor)
constant_items <- scr$item[scr$flag == "constant"]
if (length(constant_items) > 0L) {
cli::cli_abort(c(
"!" = "{length(constant_items)} item{?s} ha{?s/ve} no variance \\
(a single response value): {.val {constant_items}}.",
"i" = "{cli::qty(constant_items)}Drop {?it/them} before fitting -- a \\
constant item cannot enter a factor analysis. Use \\
{.fn check_items} to screen the rest."
))
}
# Warn only on near-constant items (a dominant response category): these are
# the ones that actually break polychoric or yield meaningless factors. A
# merely sparse category (a rare-but-present response) usually fits fine, so
# check_items() reports it but ackwards() does not warn -- avoids nagging on
# ordinary Likert data.
degenerate_items <- scr$item[scr$flag == "near-constant"]
if (length(degenerate_items) > 0L) {
cli::cli_warn(
c(
"!" = "{length(degenerate_items)} item{?s} look{?s/} near-constant \\
(one response category dominates): {.val {degenerate_items}}.",
"i" = "{cli::qty(degenerate_items)}These can destabilise \\
{.code cor = \"polychoric\"} or produce unreliable factors. \\
Inspect with {.fn check_items}; consider collapsing rare \\
categories, {.code correct = 0}, or dropping {?it/them}."
),
.frequency = "once",
.frequency_id = "ackwards_degenerate_items"
)
}
p <- ncol(data_mat)
if (k_max > p) {
cli::cli_abort("{.arg k_max} ({k_max}) cannot exceed the number of variables ({p}).")
}
# --- Missing-data preparation (M16, Invariant 6) --------------------------
# n_complete is computed from the original data regardless of deletion mode.
n_complete <- sum(stats::complete.cases(data_mat))
# Listwise: reduce to complete rows now; all downstream steps (R computation,
# engine calls, score materialisation) will use the same consistent set.
if (missing == "listwise" && n_complete < nrow(data_mat)) {
data_mat <- data_mat[stats::complete.cases(data_mat), , drop = FALSE]
}
# n_obs reflects listwise reduction (if applied); pairwise/fiml keep total N.
n_obs_eff <- nrow(data_mat)
# Pairwise advisory when data has NAs (Invariant 6 -- loud defaults).
# For ESEM ML/MLR this is especially relevant: lavaan defaults to listwise for
# model fitting while edges use pairwise correlations, which can make fit
# statistics slightly anti-conservative.
# No .frequency = "once": unlike the ordinal-detection warning (a fixed
# property of the data), this is a per-call data-state advisory -- the user
# may be iterating k_max or engine and should be reminded each time NAs are
# present with the pairwise default.
if (missing == "pairwise" && n_complete < nrow(data_mat)) {
n_incomplete <- nrow(data_mat) - n_complete
supports_fiml <- !is.null(estimator_eff) && !estimator_eff %in% c("WLSMV", "ULSMV")
cli::cli_warn(
c(
"!" = "{n_incomplete} row{?s} have missing values; correlations are \\
computed pairwise.",
if (supports_fiml) {
c("i" = "Use {.code missing = \"listwise\"} for consistent \\
complete-case analysis, or {.code missing = \"fiml\"} \\
for full-information ML.")
} else {
c("i" = "Use {.code missing = \"listwise\"} for consistent \\
complete-case analysis.")
}
)
)
}
# --- Ordinal-detection warning (DESIGN.md s.9, Invariant 6) ---------------
# Compute once; reused in meta$ordinal_warned below.
# Only warn when the user has NOT already opted into the polychoric basis.
ordinal_cols <- detect_ordinal(as.data.frame(data_mat))
is_ordinal <- length(ordinal_cols) > 0L
if (is_ordinal && cor != "polychoric") {
# Name the flagged columns so the advice is actionable for mixed data
# (M42/e3); truncate long lists to keep the warning readable.
cols_show <- cli::cli_vec(ordinal_cols, style = list("vec-trunc" = 8))
cli::cli_warn(
c(
"!" = "{length(ordinal_cols)} column{?s} look{?s/} like \\
ordinal/Likert items (<= 7 distinct integer values): \\
{.val {cols_show}}.",
"i" = "Results use a {.val {cor}} basis. Consider \\
{.code cor = \"polychoric\"} for ordinal data."
),
.frequency = "once",
.frequency_id = "ackwards_ordinal_warning"
)
}
cor_eff <- cor
missing_eff <- missing
if (!is.null(seed)) set.seed(seed)
# --- Compute correlation matrix + extract levels --------------------------
# ESEM: lavaan owns R computation for polychoric (WLSMV uses its own polychoric
# matrix internally); for continuous paths we pre-compute R and pass it through.
# PCA/EFA: always compute R here and pass to the engine.
if (engine == "esem") {
# Pre-compute edge R for non-polychoric, non-FIML paths. For FIML, R is
# extracted from lavaan's FIML saturated model inside esem_levels().
# For listwise, data_mat is already complete so pairwise.complete.obs ==
# complete.obs -- no special handling needed.
R_ext <- if (cor != "polychoric" && missing != "fiml") {
stats::cor(data_mat, method = cor, use = "pairwise.complete.obs")
} else {
NULL
}
# estimator_eff already computed above for .resolve_missing() validation.
esem_out <- esem_levels(data_mat,
k_max = k_max, estimator = estimator_eff, cor = cor,
R_external = R_ext, keep_fits = keep_fits,
missing = missing
)
levels_list <- esem_out$levels
fits_stored <- esem_out$fits
R <- esem_out$r_lv
if (is.null(R)) R <- R_ext # nocov
if (is.null(R)) { # nocov start
R <- stats::cor(data_mat, method = "pearson", use = "pairwise.complete.obs")
} # nocov end
} else {
# PCA / EFA: compute R then dispatch. Paths that already computed R's
# smallest eigenvalue record it so the shared check below reuses it (M60).
min_eig_pre <- NULL
if (cor == "polychoric") {
poly_out <- tryCatch(
psych::polychoric(data_mat, correct = correct),
error = function(e) {
cli::cli_abort(
c(
"!" = "{.fn psych::polychoric} failed: {conditionMessage(e)}",
"i" = "This usually means an item has a near-empty (singleton) \\
response category, or items have unequal category counts \\
and a sparse cross-cell. Try {.code correct = 0} \\
(psych's own suggestion; current value {.val {correct}}), \\
collapse rare categories, or drop the offending item.",
"i" = "As a last resort, use {.code cor = \"pearson\"}."
)
)
}
)
R <- poly_out$rho
# A degenerate item pair can leave NA/NaN in the matrix even when psych
# does not error; catch it before eigen() so the failure is legible
# rather than base R's "missing value where TRUE/FALSE needed".
if (anyNA(R)) { # nocov start
bad <- rownames(R)[apply(R, 1L, anyNA)]
cli::cli_abort(
c(
"!" = "The polychoric correlation matrix has undefined (NA/NaN) \\
entries -- some item pairs could not be estimated.",
"i" = "Item{?s} involved: {.val {bad}}.",
"i" = "Try {.code correct = 0}, collapse rare categories, or drop \\
the offending item{?s}."
)
)
} # nocov end
# Non-positive-definite polychoric matrices (DESIGN.md s.14 remaining):
# smooth them back to PD. cor.smooth prints its own notes -- ackwards
# owns the messaging.
min_eig_pre <- min(eigen(R, symmetric = TRUE, only.values = TRUE)$values)
if (min_eig_pre <= 0) { # nocov start
cli::cli_warn(
"Polychoric correlation matrix is not positive definite \\
(min eigenvalue = {round(min_eig_pre, 4)}); smoothing via \\
{.fn psych::cor.smooth}."
)
R <- suppressMessages(suppressWarnings(psych::cor.smooth(R)))
min_eig_pre <- min(eigen(R, symmetric = TRUE, only.values = TRUE)$values)
} # nocov end
} else if (missing == "fiml") {
# M38: FIML correlation via psych::corFiml (cor is guaranteed "pearson"
# here by .resolve_missing). The estimated R feeds the normal W'RW
# algebra unchanged; only the fit-index N choice is FIML-specific.
fiml_out <- .corfiml_R(data_mat)
R <- fiml_out$R
min_eig_pre <- fiml_out$min_eig
n_obs_eff <- switch(n_obs_mode,
total = nrow(data_mat),
complete = n_complete
)
cli::cli_inform(c(
"i" = "{.code missing = \"fiml\"}: correlation matrix estimated via \\
{.fn psych::corFiml} (full-information ML).",
if (engine == "efa") {
c(
"i" = "Fit indices use N = {n_obs_eff} \\
({.code n_obs = \"{n_obs_mode}\"}); point estimates \\
(loadings, edges) are unaffected by this choice.",
"!" = "Fit indices are approximate: a FIML correlation matrix is \\
fed into a normal-theory EFA (a two-step procedure). See \\
{.code ?ackwards} ({.arg n_obs})."
)
}
))
} else {
R <- stats::cor(data_mat, method = cor, use = "pairwise.complete.obs")
}
# Near-singular check on the final R, any basis (polychoric was smoothed
# above): records min_eigenvalue for the durable $meta signal and warns
# once. A rank-deficient matrix can also come from redundant items on the
# Pearson/Spearman/FIML bases, so this is basis-agnostic (DESIGN s6/s14.44).
min_eigenvalue <- .near_singular_check(R, cor, min_eig = min_eig_pre)
engine_out <- switch(engine,
pca = pca_levels(R,
k_max = k_max, cor = cor,
keep_fits = keep_fits
),
efa = efa_levels(R,
k_max = k_max, fm = fm, n_obs = n_obs_eff,
cor = cor, keep_fits = keep_fits
)
)
levels_list <- engine_out$levels
fits_stored <- engine_out$fits
}
} # end BRANCH B
# --- Factorability screen (M52, Invariant 6) --------------------------------
# Advisory-only: warns (never aborts, never alters the fit) when k_max exceeds
# the Ledermann identification bound (EFA/ESEM) or when sampling adequacy is
# poor (KMO < 0.5, N:p < 5). Compares against the *requested* k_max -- the
# design error to flag -- regardless of any convergence truncation below.
# n_obs_eff is NA for PCA correlation-matrix input; the screen skips the
# N-based checks accordingly. Run factorability() for the full report.
.factorability_screen(R, n_obs = n_obs_eff, p = p, k_max = k_max, engine = engine)
# --- Handle convergence truncation (PCA never truncates; EFA/ESEM may) -----
k_eff <- length(levels_list)
if (k_eff < 2L) {
cli::cli_abort(
c(
"!" = "The {engine} engine failed to build at least 2 converged levels \\
(k_eff = {k_eff}; at least 2 are required for a hierarchy).",
"i" = "Try fewer factors, or check your data for \\
perfect multicollinearity or near-singular correlation."
)
)
}
if (k_eff < k_max) {
cli::cli_warn(
c(
"!" = "Hierarchy truncated: {k_max - k_eff} level{?s} did not converge \\
(requested k_max = {k_max}, built k = {k_eff}).",
"i" = "Set {.arg k_max = {k_eff}} to suppress this message."
)
)
}
# --- Lineage matching -------------------------------------------------------
# Always adjacent: lineage and sign alignment are defined by parent-child
# relationships between consecutive levels.
raw_edges_adj <- compute_edges(
levels = levels_list,
R = R,
edge_method = "auto",
pairs = "adjacent",
build_tidy = FALSE # matrices feed lineage/sign alignment only (M60)
)
lineage <- vector("list", k_eff)
names(lineage) <- as.character(seq_len(k_eff))
for (ki in seq_len(k_eff - 1L) + 1L) {
key <- paste0(ki - 1L, ":", ki)
lineage[[as.character(ki)]] <- match_parents(raw_edges_adj$matrices[[key]])
}
# --- Sign alignment (DESIGN.md s.7) -----------------------------------------
if (align_signs) {
loadings_list <- lapply(levels_list, `[[`, "loadings")
aligned <- .align_signs(loadings_list, raw_edges_adj$matrices, lineage)
for (ki in seq_len(k_eff)) {
levels_list[[as.character(ki)]]$loadings <- aligned$loadings[[ki]]
# Flip weights consistently with loadings
levels_list[[as.character(ki)]]$scoring$weights <-
flip_weights(
levels_list[[as.character(ki)]]$scoring$weights,
aligned$signs[[ki]]
)
}
}
# --- Final edge set (adjacent or all-levels Forbes extension) ---------------
# Computed from the aligned levels_list (flipped loadings/weights), so all
# edges carry the aligned signs; the pre-alignment pass fed lineage only (M60).
final_edges <- compute_edges(
levels = levels_list,
R = R,
edge_method = "auto",
pairs = pairs,
cut_show = cut_show
)
# --- Assemble aligned edge object -------------------------------------------
edges_obj <- list(
matrices = final_edges$matrices,
tidy = fill_primary(final_edges$tidy, lineage, levels_list)
)
# --- Meta -------------------------------------------------------------------
meta <- list(
k_requested = k_max, # what the user asked for (may exceed k_eff)
# Every engine truncates at its first non-converged level (Invariant 7), so
# every built level converged and the deepest converged level is k_eff.
deepest_converged = k_eff,
pairs = pairs,
cut_show = cut_show,
ordinal_warned = is_ordinal,
missing = missing_eff,
n_complete = n_complete,
input_type = input_type,
estimator = estimator_eff %||% NA_character_,
# Chosen EFA extraction method (M47): boot_edges() refits replicate
# hierarchies and must reuse the same fm; NA for PCA/ESEM (not applicable).
fm = if (engine == "efa") fm else NA_character_,
item_labels = item_labels,
# Fit-time item moments (M45): the frame of reference for out-of-sample
# scoring. Computed from the post-`missing`-handling data (i.e. after any
# listwise reduction, consistent with the R actually fit); NULL for
# correlation-matrix input, where raw-data moments do not exist.
item_means = if (!is.null(data_mat)) colMeans(data_mat, na.rm = TRUE) else NULL,
item_sds = if (!is.null(data_mat)) apply(data_mat, 2, stats::sd, na.rm = TRUE) else NULL,
# Conditioning of the correlation matrix (durable near-singularity signal):
# `min_eigenvalue` is the smallest eigenvalue of R (NA where not computed --
# cor-matrix input, ESEM); `near_singular` (< 1e-4) means the fit rests on a
# rank-deficient matrix, so summary()/print() flag that fit indices and
# scores may be unreliable long after the fit-time warning has scrolled away.
min_eigenvalue = min_eigenvalue,
near_singular = isTRUE(min_eigenvalue < 1e-4)
)
# --- Assemble result --------------------------------------------------------
x <- new_ackwards(
call = cl,
engine = engine,
cor = cor_eff,
n_obs = n_obs_eff,
k_max = k_eff, # effective depth (may be < k_max if truncated)
seed = seed,
pkg_version = utils::packageVersion("ackwards"),
levels = levels_list,
edges = edges_obj,
lineage = lineage,
scores = NULL,
fits = NULL,
r = R,
data = NULL,
meta = meta
)
# --- Opt-in storage (scores and raw fits) -----------------------------------
# Scores use the sign-aligned levels_list so column orientations match loadings.
# keep_scores=TRUE is blocked at entry for cor_matrix input (already errored above).
if (isTRUE(keep_scores)) {
x$scores <- .compute_scores(levels_list, data_mat)
}
if (isTRUE(keep_fits)) {
x$fits <- fits_stored
}
x
}
# S3 constructor -- validates structure and attaches class
new_ackwards <- function(
call, engine, cor, n_obs, k_max, seed, pkg_version,
levels, edges, lineage, scores, fits, r, data, meta
) {
structure(
list(
call = call,
engine = engine,
rotation = "varimax",
cor = cor,
n_obs = n_obs,
k_max = k_max,
seed = seed,
pkg_version = pkg_version,
levels = levels,
edges = edges,
lineage = lineage,
scores = scores,
fits = fits,
r = r,
data = data,
meta = meta,
prune = NULL, # populated by prune() (see R/prune.R); NULL until called
boot = NULL # populated by boot_edges() (see R/boot_edges.R)
),
class = "ackwards"
)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.