Nothing
# Percent of uncontaminated correlations (PUC): the proportion of item pairs that
# do not share a group factor (Reise et al., 2013; Bonifay et al., 2015). `grp_ind`
# is a logical items-by-group-factors membership matrix (the general factor is
# excluded, as it contaminates no correlation). Each item pair sharing at least one
# group factor is a single contaminated correlation, regardless of how many group
# factors it shares.
.puc <- function(grp_ind) {
grp_ind <- as.matrix(grp_ind)
shares_grp <- (grp_ind %*% t(grp_ind)) > 0
n_items <- nrow(grp_ind)
cont_corrs <- sum(shares_grp[upper.tri(shares_grp)])
1 - cont_corrs / (n_items * (n_items - 1) / 2)
}
# H index over a construct's own loadings: the correlation between an optimally-weighted
# composite and the factor, 1 / (1 + 1 / sum(lambda^2 / (1 - lambda^2))) (Hancock & Mueller,
# 2001). A loading with |lambda| >= 1 makes the error term 1 - lambda^2 <= 0 and the summand
# undefined, so H is not interpretable for that construct; return NA -- as for a construct
# with no indicators -- rather than a negative or infinite value. For standardized loadings
# this only happens in an improper (Heywood) solution, but an oblique pattern coefficient can
# exceed 1 in a proper solution; callers flag genuine Heywood cases separately, via the unique
# variances. A missing loading (NA) is excluded from the >= 1 test and flows through the sum
# to yield NA.
.h_index <- function(lambda) {
if (length(lambda) == 0L || any(abs(lambda) >= 1, na.rm = TRUE)) return(NA_real_)
1 / (1 + 1 / sum(lambda^2 / (1 - lambda^2)))
}
# Reliability coefficients over a normalized model spec. `spec` carries the general-factor
# loadings (`g_load`), the group-factor loading matrix (`s_load`), the uniquenesses (`u2`),
# the item-to-factor correspondence map (`map`), the group-factor row labels (`fac_names`),
# the item names (`var_names`), optionally the model-implied correlation matrix of the
# variables (`implied`, see the total-variance denominators below), and -- for
# `variance = "correlation"` -- a correlation
# matrix (`cormat`) plus, for a correlated-factors solution, the factor
# intercorrelations (`Phi`, in the column order of `s_load`). `Phi` belongs to a spec
# with no general factor: it marks the group loadings as an oblique pattern whose
# factors covary, and enters the model-implied common variance in either variance mode.
# A spec that carries both a general factor and a `Phi` is not supported -- the callers that
# build a `Phi` set `g_load` to zero, and reject the combination where a user could supply
# one, because the general and group parts would no longer partition the composite.
# Given these, it computes
# McDonald's omega total, hierarchical, and subscale for the general factor and each group
# factor, and, when `add_ind = TRUE`, the H index (Hancock & Mueller, 2001) together with the
# ECV and PUC bifactor indices (Rodriguez, Reise & Haviland, 2016). Every omega numerator is
# the true score variance the model attributes to a composite, read off the model-implied
# common variance Lambda Psi Lambda'. Two total-variance denominators are supported:
# `"correlation"` takes a composite's variance from the correlation matrix, as McDonald's
# omega total does (1999, Test Theory, Eq. 6.2.1; Zinbarg, Yovel, Revelle & McDonald, 2006,
# Eqs. 4 and 6); `"sums_load"` uses the model-implied composite variance instead, and so needs
# no correlation matrix. That variance is the common variance plus the uniquenesses, unless the
# spec carries the model-implied correlation matrix of the variables in `implied`, in which case
# each composite's variance is the sum of its block of that matrix. An adapter supplies
# `implied` only where the uniquenesses do not hold the whole of the residual variance, which
# takes a fitted model with residual covariances; where they do, the two expressions are the
# same quantity and `implied` stays NULL. When
# `add_rel = TRUE`, three further columns are appended: standardized Cronbach's alpha
# (Cronbach, 1951) for the whole scale and each subscale, and -- per group factor, over its
# assigned (simple-structure) composite -- the composite reliability (congeneric omega;
# Joreskog, 1971; Raykov, 2001) and the average variance extracted (AVE, a convergent-validity
# index rather than a reliability; Fornell & Larcker, 1981). The function is purely
# computational: `spec` is assumed already normalized by the calling adapter, which owns all
# front-end input handling. `arg` names the user-facing map argument of the calling
# front-end, so the empty-factor warning points at the right one: `factor_map` for
# efa_reliability(), `factor_corres` on the frozen OMEGA() surface. Both front-ends pass
# it explicitly; the default is the frozen spelling so that a caller who forgets leaves
# OMEGA()'s wording as it has always been rather than silently changing it.
.reliability_core <- function(spec, variance = c("correlation", "sums_load"),
add_ind = TRUE, add_rel = FALSE,
arg = "factor_corres") {
variance <- match.arg(variance)
g_load <- spec$g_load
s_load <- spec$s_load
u2 <- spec$u2
factor_corres <- spec$map
cormat <- spec$cormat
var_names <- spec$var_names
# The map states membership, so only 0 and 1 (or the logicals they stand for) mean
# anything. Any other value is read two ways below and cannot be read one way: the
# composites are the entries equal to 1, while the map is also multiplied into the
# loadings and the uniquenesses, where it weights them. A map of 2s therefore names the
# same composites but empties every one of them -- the coefficients come back NA under a
# whole-scale row that still looks right -- so it is refused rather than resolved.
# Checked here rather than in the front ends because this is where every route meets: the
# derived maps are 0/1 or logical by construction, but a user's arrives through several
# callers, not all of which validate it.
# %in% is match-based, so a missing entry answers FALSE rather than propagating.
if (!(is.logical(factor_corres) || is.numeric(factor_corres)) ||
!all(factor_corres %in% c(0, 1))) {
vals <- unique(as.vector(as.matrix(factor_corres)))
other <- utils::head(vals[!vals %in% c(0, 1)], 3L)
cli::cli_abort(
c("{.arg {arg}} must hold only 0 and 1.",
"x" = "It also holds {.val {other}}.",
"i" = "Mark each variable's group factors with 1 and every other entry with 0."),
class = "efa_reliability_map_values"
)
}
# Same general + group factor labels regardless of how s_load was obtained.
factor_names <- c("g", seq_len(ncol(s_load)))
# Create an input dataframe
input <- data.frame(g_load, s_load)
colnames(input) <- factor_names
rownames(input) <- var_names
input$u2 <- u2
# seq_len(k) + 1 rather than 2:(k + 1): the latter counts down to c(2, 1) when a spec
# arrives with no group factors, selecting the general column twice over.
omega_mat <- input[, seq_len(ncol(s_load)) + 1L, drop = FALSE] * factor_corres
# Sum of all g loadings
sum_g <- sum(input$g)
# Sum of all error variances
sum_e <- sum(input$u2)
# Sums of error variances and of loadings over each group factor's assigned items.
sums_e_s <- colSums(input$u2 * factor_corres)
sums_s_s <- colSums(omega_mat)
# The variables a group factor's composite is made of. The omega numerators and
# denominators are formed from this one index vector, so each coefficient's two halves
# always describe the same composite. The map is 0/1 by the check above, so these are the
# same items the masked sums above are taken over.
members <- lapply(seq_len(ncol(s_load)), function(i) which(factor_corres[, i] == 1))
# Model-implied common variance of the variables, Lambda Psi Lambda'. The general factor
# is an extra column orthogonal to the group factors, so the quadratic form splits into a
# general and a group part; Psi is the identity for a bifactor or Schmid-Leiman solution
# and the factor intercorrelations Phi for a correlated-factors one, whose general column
# is zero throughout (see above). Summing the block belonging to a set of variables gives
# that composite's model-implied true score variance, 1' Lambda Psi Lambda' 1, which is the
# numerator of every omega total below (Zinbarg, Revelle, Yovel & Li, 2005, Eq. 8; Zinbarg,
# Yovel, Revelle & McDonald, 2006, Eq. 6). Its diagonal holds the communalities.
s_mat <- as.matrix(input[, seq_len(ncol(s_load)) + 1L, drop = FALSE])
Phi <- spec$Phi
implied <- spec$implied
group_common <- if (is.null(Phi)) tcrossprod(s_mat) else s_mat %*% Phi %*% t(s_mat)
common <- tcrossprod(input$g) + group_common
# The coefficients below describe the unit-weighted sum of the variables as supplied, so
# flag a solution whose variables are not all keyed in the same direction before any of
# them is reported.
.rel_check_keying(common, var_names)
if(variance == "correlation"){
# Compute omega total, hierarchical, and subscale for g-factor
omega_tot_g <- sum(common) / sum(cormat)
omega_h_g <- sum_g^2 / sum(cormat)
omega_sub_g <- sum(sums_s_s^2) / sum(cormat)
# Compute omega total, hierarchical, and subscale for group factors
omega_tot_sub <- NULL
omega_h_sub <- NULL
omega_sub_sub <- NULL
# A composite's omega total is the true score variance the model attributes to it over
# its observed variance. Every factor the composite's variables load on contributes:
# with correlated factors through the cross-loadings and the factor correlations, and in
# an estimated bifactor or Schmid-Leiman solution through the cross-loadings alone,
# which such a solution always has -- only a confirmatory bifactor model carries
# structural zeros off a composite's own column. Counting the general factor and the
# composite's own column only would report that factor's congeneric omega, which the
# omega subscale column already reports, under the name of a total. Reading the
# composite's block of `common` counts every factor, whichever kind of solution the spec
# holds.
for (i in seq_len(ncol(s_load))) {
subf <- members[[i]]
Vgr <- sum(cormat[subf, subf])
omega_tot_sub[i] <- sum(common[subf, subf]) / Vgr
omega_h_sub[i] <- sum(input$g[subf])^2 / Vgr
omega_sub_sub[i] <- sum(s_mat[subf, i])^2 / Vgr
}
} else if(variance == "sums_load") {
# Sums of all group factor loadings for all group factors
sums_s <- colSums(s_mat)
# What the group factors contribute to the whole-scale composite, 1' S Psi S' 1. For
# uncorrelated group factors that is the sum of the squared loading-column sums; for a
# correlated-factors spec the factor correlations enter it as the quadratic form
# (S'1)' Phi (S'1), which the squared column sums alone would drop. The subscale rows
# below read the Phi-aware `common`, so scoring this row from the column sums would
# describe an orthogonal model and a correlated one in the same table. The
# uncorrelated branch keeps the literal expression, so nothing about the arithmetic of
# a bifactor or Schmid-Leiman solution changes.
group_var <- if (is.null(Phi)) sum(sums_s^2) else sum(sums_s * (Phi %*% sums_s))
# Compute omega total, hierarchical, and subscale for the whole scale. The
# composite here is all variables, so every loading on every group factor
# contributes to its variance: the general and group terms use the full
# loading-column sums, which add up to the whole of the model-implied common
# variance. omega total is then 1 - sum(u^2) / V, with V
# the model-implied composite variance, and the general and group variances
# partition it exactly (tot = hier + sub for the g row).
#
# V comes from `implied` where the spec carries it. There the residual covariances hold
# variance that `sum_e`, the sum of the residual variances alone, does not count
# (Raykov, 2001). The numerators are the common variance either way, so the partition
# is unaffected.
var_g <- if (is.null(implied)) sum_g^2 + group_var + sum_e else sum(implied)
omega_tot_g <- (sum_g^2 + group_var) / var_g
omega_h_g <- sum_g^2 / var_g
omega_sub_g <- group_var / var_g
# Compute omega total, hierarchical, and subscale for group factors. A subscale
# composite's model-implied variance is the common variance it receives from every
# factor plus its unique variances, so the denominator reads that composite's block
# of `common` exactly as the whole-scale row above reads the whole matrix. Counting
# only the general and own-column terms would leave the variance the composite
# receives through cross-loadings on the other group factors out of both the
# numerator and the denominator, which understates omega total and inflates omega
# hierarchical and omega subscale. The three add up on the whole-scale row but not
# here, where omega subscale stays the factor's own contribution while omega total
# counts them all.
omega_tot_sub <- NULL
omega_h_sub <- NULL
omega_sub_sub <- NULL
for (i in seq_len(ncol(s_load))) {
subf <- members[[i]]
common_sub <- sum(common[subf, subf])
# As on the whole-scale row: that composite's block of `implied` where the spec
# carries one, its own unique variances otherwise.
Vsub <- if (is.null(implied)) common_sub + sum(u2[subf]) else
sum(implied[subf, subf])
omega_tot_sub[i] <- common_sub / Vsub
omega_h_sub[i] <- sum(input$g[subf])^2 / Vsub
omega_sub_sub[i] <- sum(s_mat[subf, i])^2 / Vsub
}
}
# A group factor with no assigned items (an all-zero map column, e.g. a
# type = "psych" factor that is never the largest loading for any item) has a
# zero subscale-variance denominator and hence undefined coefficients. Report
# them as NA -- consistent with the H index below -- instead of a silent NaN.
empty_fac <- which(colSums(factor_corres == 1) == 0)
if (length(empty_fac) > 0) {
omega_tot_sub[empty_fac] <- NA
omega_h_sub[empty_fac] <- NA
omega_sub_sub[empty_fac] <- NA
cli::cli_warn(
c("Some group factors have no assigned items in {.arg {arg}}.",
"i" = "Their omega coefficients and H index are returned as {.val NA}."),
class = "efa_omega_empty_factor"
)
}
# Combine and display results in a table
omega_tot <- c(omega_tot_g, omega_tot_sub)
omega_h <- c(omega_h_g, omega_h_sub)
omega_sub <- c(omega_sub_g, omega_sub_sub)
# A share of variance above 1, or a general-factor share above the share of all factors
# together, is not an admissible reliability whichever total variance it was divided by.
# The two conventions get there by different routes, so the remedy differs with them.
#
# In "correlation" mode the denominator (the correlation matrix) is independent of the
# loading-based numerators, so a correlation matrix inconsistent with the supplied loadings
# can push a coefficient above 1. It takes a composite whose loadings over-predict its
# observed variance by more than the whole of its unique variance, which cannot happen when
# the loadings were estimated from that same matrix, but can when the two come from
# different sources.
#
# In "sums_load" mode the denominator is that composite's own common variance plus its own
# unique variances, so an omega total above 1 takes those uniquenesses to sum to something
# negative -- an improper solution, which the Heywood check below reports from the
# uniquenesses themselves, except that that check is gated on `add_ind` while this one is
# not. Omega subscale reaches it by a second route that needs no improper solution at all:
# it divides a group factor's own squared loading sum by its composite's whole variance,
# and once the group factors are correlated that sum is not a part of it, because the
# composite's cross-loadings on the other factors can contribute negative common variance
# and take the total below what its own factor puts in.
#
# Either way, a set of factor correlations that is not positive semi-definite makes the
# group factors contribute negative common variance, which can put omega hierarchical above
# omega total. Warn rather than return any of these silently.
tol <- .Machine$double.eps^0.5
if (any(c(omega_tot, omega_h, omega_sub) > 1 + tol, na.rm = TRUE) ||
any(omega_h > omega_tot + tol, na.rm = TRUE)) {
# Without a general factor omega hierarchical is zero throughout, so the second clause
# there says an omega total came out negative -- and the front-end drops the hierarchical
# column for such a solution, so naming it would point at a coefficient the result does
# not carry.
lead <- if (isTRUE(all(input$g == 0))) {
"Some omega coefficients are out of range (above 1, or a negative omega total)."
} else {
"Some omega coefficients are out of range (above 1, or omega hierarchical above omega total)."
}
# The uniquenesses are named rather than pointed at as {.arg u2}: they are only an
# argument on the manual and bare-matrix routes, and come from the model on the others.
hints <- if (variance == "correlation") {
"Check that {.arg cormat} is consistent with the loadings."
} else {
"Check the uniquenesses of the affected composite -- its own subtotal, not their sum over all variables -- for a negative value."
}
if (!is.null(Phi)) {
hints <- c(hints, paste("Omega subscale can also exceed 1 in a proper solution, when a",
"composite's cross-loadings on the other group factors reduce its",
"variance below what its own factor contributes; inspect those and",
"{.field Phi}."))
}
names(hints) <- rep("i", length(hints))
cli::cli_warn(c(lead, hints), class = "efa_omega_out_of_range")
}
# Optional reliability / convergent-validity coefficients, appended as columns when
# requested. Kept behind add_rel so the OMEGA coefficient menu is unchanged otherwise.
if (isTRUE(add_rel)) {
# Standardized Cronbach's alpha (Cronbach, 1951), k / (k - 1) *
# (1 - sum(diag(R_sub)) / sum(R_sub)), for the whole scale (all items) and each
# subscale (the map's assigned items). It needs a correlation matrix: the supplied
# cormat; else the model-implied one the spec carries in `implied`, which already has a
# unit diagonal; else the model-implied common variance plus the uniquenesses (which
# assumes the model holds and that the residual covariance matrix is diagonal),
# standardized to a unit diagonal with cov2cor so the formula returns the standardized
# coefficient even when the loadings and uniquenesses do not complete to unit item
# variance. Standardized alpha uses the correlation matrix; raw, covariance-based alpha
# would need item standard deviations the spec does not carry. Alpha is undefined for
# fewer than two items (returned NA).
R_rel <- if (!is.null(cormat)) {
cormat
} else if (!is.null(implied)) {
implied
} else {
stats::cov2cor(common + diag(u2, nrow = length(u2)))
}
composites <- c(list(seq_len(nrow(s_mat))), members)
alpha <- vapply(composites, function(idx) {
k <- length(idx)
if (k < 2L) return(NA_real_)
Rk <- R_rel[idx, idx, drop = FALSE]
k / (k - 1) * (1 - sum(diag(Rk)) / sum(Rk))
}, numeric(1))
# Composite reliability (congeneric omega; Joreskog, 1971; Raykov, 2001) and average
# variance extracted (AVE; Fornell & Larcker, 1981) per group factor, over that
# factor's assigned composite (simple structure): CR = (sum L)^2 / ((sum L)^2 +
# sum u2), AVE = sum L^2 / (sum L^2 + sum u2), summed over the assigned items only --
# a foreign item's cross-loading never enters the subscale score, so it must not enter
# its reliability. AVE is a convergent-validity index, not a reliability. Both describe
# a single group factor, so the general-factor (whole-scale) row is NA. sums_s_s and
# sums_e_s are the assigned loading and uniqueness sums already formed for the omegas,
# and omega_mat is the map-masked group loadings, so colSums(omega_mat^2) is the
# assigned sum of squared loadings.
sq_assigned <- colSums(omega_mat^2)
CR <- c(NA_real_, sums_s_s^2 / (sums_s_s^2 + sums_e_s))
AVE <- c(NA_real_, sq_assigned / (sq_assigned + sums_e_s))
# A group factor with no assigned items has no composite: NA, as for the omegas.
# CR and AVE would otherwise be 0 / 0 = NaN; alpha is already NA for an empty
# composite via its k < 2 guard, so only CR and AVE need the explicit override.
if (length(empty_fac) > 0) {
CR[empty_fac + 1] <- NA
AVE[empty_fac + 1] <- NA
}
}
if(isTRUE(add_ind)){
# Compute H index, ECV, and PUC
h_s <- vector("double", ncol(s_load))
h_s_load <- vector("double", ncol(s_load))
hiload_s <- logical(ncol(s_load))
# The H index (Hancock & Mueller, 2001) of a group factor is defined over that factor's
# own indicators, so its loadings are restricted to the items assigned to the factor by
# the map; .h_index guards its denominator against a loading of absolute value at least 1.
# `hiload_s` flags, per group factor, such a loading on an item with a positive unique
# variance -- i.e. an oblique pattern coefficient >= 1 in a proper solution, not a Heywood
# case -- so its undefined H is reported separately from (and independently of) any Heywood
# case below. ECV (Rodriguez et al., 2016) is a ratio of common variances and uses the
# full group-factor loading columns. The map defines item-to-factor membership, which also
# enters the PUC.
for (j in seq_len(ncol(s_load))){
s_j <- input[[j + 1]]
mem <- factor_corres[, j] == 1
s_mem <- s_j[mem]
h_s[j] <- .h_index(s_mem)
hiload_s[j] <- length(s_mem) > 0 &&
any(abs(s_mem) >= 1 & u2[mem] >= .Machine$double.eps, na.rm = TRUE)
h_s_load[j] <- sum(s_j^2)
}
h_g <- .h_index(input$g)
hiload_g <- any(abs(input$g) >= 1 & u2 >= .Machine$double.eps, na.rm = TRUE)
# Heywood case: an improper solution, i.e. a unique variance at or below zero
# (equivalently a communality at or above 1). This is the standard definition, as in
# EFA() and the lavaan adapter, and is a property of the solution rather than of the H
# index, so it is flagged from the uniquenesses (not the loadings). Coefficients
# involving the affected variables may not be interpretable. which() drops any NA
# uniqueness (a malformed component, not a Heywood case) so it neither triggers a
# false Heywood warning nor suppresses the h-undefined note below.
heywood_idx <- which(u2 < .Machine$double.eps)
heywood_vars <- if (is.null(var_names)) as.character(heywood_idx) else
var_names[heywood_idx]
if (length(heywood_vars) > 0) {
cli::cli_warn(
c(paste("{cli::qty(heywood_vars)}Heywood case{?s} detected for {.val {heywood_vars}}:",
"a communality at or above 1 leaves a non-positive unique variance."),
"i" = "{cli::qty(heywood_vars)}Coefficients involving the affected variable{?s} may not be interpretable."),
class = "efa_reliability_heywood"
)
}
# Separately, the H index is undefined for any construct with a loading whose absolute
# value is at least 1 (its 1 - lambda^2 error term is non-positive), so .h_index has
# returned NA for it. For standardized (orthogonal) loadings a loading >= 1 only occurs
# in a Heywood case, already flagged above from the uniquenesses; hiload_* count only the
# constructs whose offending loading has a positive unique variance (an oblique pattern
# coefficient in a proper solution), so this note is per-construct -- it fires for such a
# construct even when an unrelated Heywood case exists elsewhere, and stays silent when
# the loading >= 1 is itself the Heywood case.
if (isTRUE(hiload_g) || any(hiload_s)) {
cli::cli_warn(
c("The H index could not be computed for some constructs (a loading with an absolute value of at least 1).",
"i" = "The H index is returned as {.val NA} for those constructs."),
class = "efa_reliability_h_undefined"
)
}
ECV <- sum(input$g^2) / sum(sum(input$g^2), sum(h_s_load))
# Proportion of uncontaminated correlations from the map membership (matching
# the H index; the general factor is not a contaminant; see .puc).
PUC <- .puc(factor_corres == 1)
# Create output
h <- c(h_g, h_s)
omegas <- cbind(omega_tot, omega_h, omega_sub, h, NA, NA)
omegas[1, 5] <- ECV
omegas[1, 6] <- PUC
colnames(omegas) <- c("tot", "hier", "sub", "H", "ECV", "PUC")
} else {
omegas <- cbind(omega_tot, omega_h, omega_sub)
colnames(omegas) <- c("tot", "hier", "sub")
}
# Append the reliability / convergent-validity columns when computed.
if (isTRUE(add_rel)) {
omegas <- cbind(omegas, alpha = alpha, CR = CR, AVE = AVE)
}
rownames(omegas) <- c("g", spec$fac_names)
class(omegas) <- "OMEGA"
omegas
}
# Front-end adapters: normalize each reliability input source to the spec consumed
# by .reliability_core(). Every adapter returns the same list
# (g_load, s_load, u2, map, cormat, var_names, fac_names), so the core stays
# input-agnostic and the front-end only has to pick the right adapter.
# Resolve the item-to-factor correspondence map for adapters that take a separate
# `factor_corres`/`type` (SL, schmid, manual, efa): with a supplied `factor_corres`,
# validate its dimensions and use it; otherwise, for `type = "psych"`, assign each
# item to its highest-|loading| group factor (as psych::omega does); `type =
# "EFAtools"` requires an explicit `factor_corres`. `arg` names the user-facing
# argument the map came from, as in .rel_check_map().
.rel_map <- function(s_load, factor_corres = NULL, type = c("EFAtools", "psych"),
arg = "factor_map") {
type <- match.arg(type)
s_load <- as.matrix(s_load)
if (!is.null(factor_corres)) {
.rel_assert_map_dim(factor_corres, s_load, arg = arg)
.rel_check_map(s_load, factor_corres, arg = arg)
return(factor_corres)
}
if (type == "EFAtools") {
cli::cli_abort(
"Specify {.arg {arg}}, or set {.code type = \"psych\"} to derive variable-to-factor correspondences from the highest group-factor loading per variable.",
class = "efa_reliability_need_corres"
)
}
# type == "psych": each variable's correspondence is its largest |group loading|.
map <- matrix(0, nrow = nrow(s_load), ncol = ncol(s_load))
for (i in seq_len(nrow(s_load))) {
map[i, which.max(abs(s_load[i, ]))] <- 1
}
map
}
# Abort when a user-supplied item-to-factor map does not conform to the group loadings
# it will be applied to: one row per variable and one column per group factor. Without
# this check a map with too few rows is recycled against the loadings and returns
# coefficients above 1 rather than an error. `arg` names the user-facing argument the map
# came from, as in .rel_check_map().
.rel_assert_map_dim <- function(map, s_load, arg = "factor_map") {
s_load <- as.matrix(s_load)
if (!is.matrix(map) || !identical(dim(map), dim(s_load))) {
got <- if (is.matrix(map)) {
"{.arg {arg}} has {nrow(map)} row{?s} and {ncol(map)} column{?s}."
} else {
"{.arg {arg}} is not a matrix."
}
cli::cli_abort(
c("{.arg {arg}} must have one row per variable and one column per group factor.",
"x" = got,
"i" = "The solution has {nrow(s_load)} variable{?s} and {ncol(s_load)} group factor{?s}."),
class = "efa_reliability_map_dim"
)
}
invisible(map)
}
# Sanity-check a user-supplied item-to-factor map against the loadings it will be
# applied to. The map's columns are matched to the group factors by position, so a map
# written in a different factor order than the solution yields well-formed but
# meaningless subscale coefficients rather than an error. Flag each column whose
# assigned items barely load on it (mean |loading| below `min_load`) while the same
# items do load on some other column -- the signature of a permuted or mistranscribed
# map. Requiring a better home for the items keeps the check to that signature: a map
# whose items load on nothing is a weak solution, not a misaligned map, and is left
# alone. `min_load` is deliberately far below the salience conventions used elsewhere
# in the package (`salience_threshold`, .3 by default): a deliberate map may well
# disagree with the loadings, so only assignments that are essentially unsupported are
# flagged. Silent by construction for a map derived by .rel_map(): there each item is
# assigned to its own largest |loading|, so a column's assigned mean can never fall
# below another column's mean for those items. `arg` names the user-facing argument the
# map came from, so the message points at the right one for each front-end. A map that
# does not conform to the loadings is left to the caller, which validates the dimensions.
.rel_check_map <- function(s_load, map, min_load = 0.1, arg = "factor_map") {
s_load <- abs(as.matrix(s_load))
map <- as.matrix(map)
if (!identical(dim(map), dim(s_load))) return(invisible(NULL))
assigned <- map == 1
labs <- colnames(s_load)
if (is.null(labs)) labs <- as.character(seq_len(ncol(s_load)))
implausible <- vapply(seq_len(ncol(assigned)), function(j) {
idx <- which(assigned[, j])
# An empty column carries no items to judge; the core reports it separately.
if (length(idx) == 0L) return(FALSE)
own <- mean(s_load[idx, j])
others <- colMeans(s_load[idx, -j, drop = FALSE])
isTRUE(own < min_load) && any(others > min_load, na.rm = TRUE)
}, logical(1))
if (any(implausible)) {
bad <- labs[implausible]
cli::cli_warn(
c("{cli::qty(bad)}The items assigned to {.arg {arg}} column{?s} {.val {bad}} hardly load on {?that/those} group factor{?s}, but do load on another one.",
"i" = "The columns of {.arg {arg}} are matched to the group factors by position; check that they are in the column order of the solution."),
class = "efa_reliability_implausible_map"
)
}
invisible(NULL)
}
# Warn when the variables of a solution are not all keyed in the same direction -- most
# often a scale whose reverse-worded items were never reverse-coded. Every coefficient
# computed here describes the raw unit-weighted sum of the variables the solution was fitted
# on, so a
# variable keyed against the rest subtracts from that sum's true score variance instead of
# adding to it and the coefficients collapse: correct for the sum that was scored, but
# reading as a poor scale rather than as a missing step. The variables are deliberately not
# reflected first, as psych::omega does, because reflection describes a different composite
# than the one the coefficients are defined on (Flora, 2020).
#
# "The variables the solution was fitted on" is meant literally: fitted to polychoric or
# tetrachoric correlations, or to an `ordered` lavaan fit, the composite is of the latent
# responses assumed to underlie the ordinal answers, not of the answers themselves, and the
# keying rule below reads that composite. The distinction does not change the rule -- a
# variable works against the sum on either metric -- but it does change what the reported
# coefficients are the reliability OF. See the Details of efa_reliability() and Green and
# Yang (2009) for the ordinal-score alternative, which is not computed here.
#
# `common` is the model-implied common variance Lambda Psi Lambda' of the variables, whose
# total sum(common) = 1' Lambda Psi Lambda' 1 is the composite's true score variance. Under
# any re-keying D = diag(+-1) of the variables that variance becomes sum_ij d_i d_j
# common_ij, which is at most sum(abs(common)); the ratio of the two is therefore 1 exactly
# when no variable works against the sum and falls towards 0 as the keying becomes balanced.
# The rule reads the common variance rather than the loading signs on purpose: `g_load` is
# zero throughout for a correlated-factors spec, so a sign test on it never fires there, and
# the group loadings carry no sign information either, because the factor columns are
# reflected -- a reverse-keyed block comes back with positive loadings and a negative factor
# correlation, which the common variance shows and the loadings do not.
#
# `cutoff` is deliberately far below any well-keyed solution, as `min_load` is in
# .rel_check_map(): the ratio is (P - N) / (P + N) over the positive and negative common
# variance, so firing takes negative common variance exceeding about a seventh of the
# positive. On the bundled data no correctly keyed solution reaches that: the lowest is .89
# (DOSPERT_raw at two factors, whose domains are only weakly related), whereas a scale whose
# reverse-worded items were never reverse-coded stays below .32 (UPPS_raw, two to six
# factors) and reverse-coding one factor's block of a well-keyed solution takes it to .23.
#
# The variables named are those whose own common variance with the composite, the row sum of
# `common`, is negative -- the ones subtracting from the sum, and so the ones to reverse-code.
# They are named for guidance and not as the trigger: a variable can drag the total down
# without its own row sum turning negative, so the ratio decides whether to warn and the row
# sums only say where to look. The list is therefore allowed to come out empty, in which case
# the message states the finding without it. `var_names` may be NULL, as for the Heywood
# report above, in which case the positions stand in for the names.
.rel_check_keying <- function(common, var_names = NULL, cutoff = 0.75) {
aligned <- sum(abs(common))
# Nothing to judge: no loadings at all, or a malformed component (NA) the callers
# report separately.
if (!is.finite(aligned) || aligned <= .Machine$double.eps^0.5) return(invisible(NULL))
if (isTRUE(sum(common) < cutoff * aligned)) {
against <- which(rowSums(common) < 0)
n_bad <- length(against)
lead <- if (n_bad > 0) {
labs <- if (is.null(var_names)) as.character(against) else var_names[against]
# Capped as elsewhere: a long scale can have dozens of reverse-worded items, and a
# message that prints them all is unreadable.
cap <- .cap_label_list(labs)
paste("The variables are not all keyed in the same direction:",
"{.val {cap$shown}}{cap$rest} {cli::qty(n_bad)}{?is/are} keyed against the rest.")
} else {
"The variables are not all keyed in the same direction."
}
cli::cli_warn(
c(lead,
"i" = "The coefficients describe the raw unit-weighted sum of the variables as supplied, in which the reversed variables cancel rather than add.",
"i" = "Reverse-code them before fitting the solution (Flora, 2020)."),
class = "efa_reliability_mixed_keying"
)
}
invisible(NULL)
}
# Warn (classed) when supplied uniquenesses do not complete the supplied loadings to unit
# variance. `h2` is the diagonal of the model-implied common variance Lambda Psi Lambda',
# so a standardized solution has h2 + u2 = 1 for every variable. Only the routes whose
# uniquenesses come from the user need this: every fitted route derives them as 1 - h2, and
# so satisfies it by construction.
#
# What it catches is a slip on adjacent columns. The printed Schmid-Leiman table ends in
# `h2` and `u2`, and many published tables report only `h2`, leaving the reader to form
# `1 - h2` by hand -- so passing communalities where uniquenesses belong is a one-character
# mistake rather than a contrived one. It is silent otherwise: `u2` does not enter the
# coefficients at all under `variance = "correlation"` with a correlation matrix, and that
# is the mode a user without one is turned away from, so the mistake is inert exactly where
# it would be harmless and load-bearing exactly where it is not.
#
# A warning rather than an error, because unstandardized (covariance-metric) components are
# a defensible input that yields a legitimate raw-score coefficient; the user needs to know
# the components are not standardized, not to be stopped. `tol` sits well clear of both
# sides: a published table rounded to two decimals leaves a residual of about .015, while
# supplying communalities leaves at least .40.
.rel_check_u2 <- function(h2, u2, tol = 0.05) {
# Nothing to compare: no variables at all, or a `u2` that does not line up with the
# loadings, which is the callers' error to report and not a statement about
# standardization. Either would otherwise reach `max()` over an empty vector or compare a
# recycled one.
if (length(h2) == 0L || length(h2) != length(u2)) return(invisible(NULL))
resid <- max(abs(h2 + u2 - 1))
# A non-finite residual is a malformed component, which the callers report separately.
if (!isTRUE(resid > tol)) return(invisible(NULL))
cli::cli_warn(
c("The supplied {.arg u2} do not complete the loadings to unit variance.",
"x" = "A communality plus its uniqueness differs from 1 by up to {round(resid, 3)}.",
"i" = "A standardized solution has {.code u2 = 1 - h2}; check that {.arg u2} holds uniquenesses rather than communalities, and that they belong to these loadings.",
"i" = "Unstandardized components are scored as given, and yield the coefficients of the raw composite."),
class = "efa_reliability_u2_not_standardized"
)
invisible(NULL)
}
# Abort (classed) unless `Phi` can be the factor intercorrelation matrix of the `k` group
# factors it will be scored with. `NULL` means the identity -- uncorrelated group factors, as a
# Schmid-Leiman or bifactor solution has by construction -- and is always admissible, so this
# only runs where a matrix was actually supplied.
#
# The checks matter because `Phi` enters the model-implied common variance as the quadratic form
# S Phi S', where a matrix that is not a correlation matrix does not merely mislabel the solution:
# an asymmetric one is silently symmetrised by the quadratic form, a non-unit diagonal rescales
# the factors, and one that is not positive semi-definite attributes negative common variance to a
# composite and returns coefficients outside [0, 1] with nothing else to catch them.
#
# The shape tests are written out rather than delegated to .is_cormat(): that helper classifies
# raw data against a correlation matrix, so it rejects by design the 1 x 1 matrix a single group
# factor takes, and it aborts on a missing value instead of reporting one. Positive
# semi-definiteness is judged on the smallest eigenvalue against the package's usual tolerance --
# an estimated factor correlation matrix is often singular to rounding, which is admissible, while
# a genuinely indefinite one is not.
.rel_check_phi <- function(Phi, k, arg = "Phi") {
if (is.null(Phi)) return(invisible(NULL))
bad <- function(detail) {
cli::cli_abort(
c("{.arg {arg}} must be the correlation matrix of the {k} group factor{?s}.",
"x" = detail,
"i" = "Leave it {.val NULL} for uncorrelated group factors."),
class = "efa_reliability_bad_phi"
)
}
if (!is.matrix(Phi) || !is.numeric(Phi)) {
bad("It is {.obj_type_friendly {Phi}}, not a numeric matrix.")
}
if (nrow(Phi) != k || ncol(Phi) != k) {
bad("It is {nrow(Phi)} by {ncol(Phi)}, not {k} by {k}.")
}
if (anyNA(Phi)) {
bad("It contains missing values.")
}
tol <- .Machine$double.eps^0.5
if (any(abs(Phi - t(Phi)) > tol)) {
bad("It is not symmetric.")
}
if (any(abs(diag(Phi) - 1) > tol)) {
bad("Its diagonal is not all ones.")
}
# Symmetric by the test above, so the eigenvalues are real.
if (min(eigen(Phi, symmetric = TRUE, only.values = TRUE)$values) < -tol) {
bad("It is not positive semi-definite, so it is not a correlation matrix.")
}
invisible(NULL)
}
# Abort (classed) when a supplied correlation matrix fails the .is_cormat() check;
# shared guard for the adapters that accept a user cormat. A correlation matrix supplied as a
# data frame -- what read.csv() returns for a published correlation table -- is accepted and
# handed back as a matrix, as .prepare_cor_input() does on the other input route. The coercion
# is load-bearing for one operation only: the standardized-alpha branch of .reliability_core()
# takes diag() of a subset of it, which a data frame does not support. The omega quantities
# only sum and subset it, which a data frame does support -- so without the coercion they
# would return correct numbers and only add_rel = TRUE would fail. Keep the coercion here
# rather than at the one operation, so the spec carries a single type.
#
# The matrix is also checked against the solution it will be used with, given as the
# number of variables (`n_items`) and, where the solution carries them, their names
# (`var_names`). The subscale coefficients divide loading sums by the correlations of the
# same items, so a correlation matrix of other variables, or of the same variables in
# another order, yields well-formed but wrong subscale coefficients while leaving the
# whole-scale row -- which only sums the whole matrix -- untouched, and one of the wrong
# size fails much later as an out-of-bounds subscript. Named variables in a different
# order are an unambiguous permutation and are reordered to the solution; a different set
# of names cannot be resolved and aborts.
.rel_check_cormat <- function(cormat, var_names = NULL, n_items = NULL) {
if (!.is_cormat(cormat)) {
cli::cli_abort(
c("{.arg cormat} is not a correlation matrix.",
"i" = "Check the {.arg cormat} input, or leave it {.code NULL}."),
class = "efa_reliability_not_cormat"
)
}
cormat <- as.matrix(cormat)
if (is.null(n_items) && !is.null(var_names)) n_items <- length(var_names)
if (!is.null(n_items) && nrow(cormat) != n_items) {
cli::cli_abort(
c("{.arg cormat} must have one row and column per variable in the solution.",
"x" = "{.arg cormat} has {nrow(cormat)} variable{?s}, but the solution has {n_items}.",
"i" = "Supply the correlation matrix of the variables in the solution."),
class = "efa_reliability_cormat_dim"
)
}
# Only labelled variables can be matched. An unlabelled correlation matrix, or an
# unlabelled solution, is matched by position as before.
rn <- rownames(cormat)
cn <- colnames(cormat)
if (!is.null(rn) && !is.null(cn) && !identical(rn, cn)) {
cli::cli_abort(
c("The row and column names of {.arg cormat} differ.",
"i" = "Use the same names on both axes, or remove them to match by position."),
class = "efa_reliability_cormat_names"
)
}
if (is.null(rn)) rn <- cn
if (!is.null(var_names) && !is.null(rn) && !identical(rn, var_names)) {
if (anyDuplicated(rn) == 0L && anyDuplicated(var_names) == 0L &&
setequal(rn, var_names)) {
idx <- match(var_names, rn)
cormat <- cormat[idx, idx, drop = FALSE]
} else {
cli::cli_abort(
c("The variables of {.arg cormat} are not the variables of the solution.",
"i" = "Supply the correlation matrix of the solution's variables, in any order."),
class = "efa_reliability_cormat_names"
)
}
}
cormat
}
# Adapter: normalize an SL() object to the reliability spec. Mirrors the SL branch of
# .OMEGA_FLEX. Honours a user cormat, else the SL object's stored correlation matrix
# (orig_R = NA for flexible-input SL objects, in which case cormat stays NULL and
# only variance = "sums_load" applies).
.rel_adapt_SL <- function(model, factor_corres = NULL, type = "EFAtools",
cormat = NULL, fac_names = NULL) {
sl <- model$sl
var_names <- rownames(sl)
g_load <- sl[, 1]
s_load <- sl[, 2:(ncol(sl) - 2), drop = FALSE]
u2 <- sl[, "u2"]
if (is.null(cormat)) {
if (is.matrix(model$orig_R)) cormat <- model$orig_R
} else {
cormat <- .rel_check_cormat(cormat, var_names, nrow(sl))
}
if (is.null(fac_names)) fac_names <- colnames(s_load)
list(g_load = g_load, s_load = s_load, u2 = u2,
map = .rel_map(s_load, factor_corres, type),
cormat = cormat, var_names = var_names, fac_names = fac_names)
}
# Adapter: normalize a psych::schmid() object to the reliability spec. Mirrors the
# schmid branch of .OMEGA_FLEX. When no cormat is supplied, it is reconstructed from
# the schmid pattern matrix and factor intercorrelations via psych::factor.model.
.rel_adapt_schmid <- function(model, factor_corres = NULL, type = "psych",
cormat = NULL, fac_names = NULL) {
pattern <- model$oblique
Phi <- model$phi
sl <- model$sl
s_load_names <- setdiff(colnames(sl[, -1]), c("h2", "u2", "p2", "com"))
var_names <- rownames(sl)
g_load <- sl[, 1]
s_load <- sl[, s_load_names, drop = FALSE]
u2 <- sl[, "u2"]
if (is.null(cormat)) {
cormat <- psych::factor.model(f = pattern, Phi = Phi, U2 = FALSE)
} else {
cormat <- .rel_check_cormat(cormat, var_names, nrow(sl))
}
if (is.null(fac_names)) fac_names <- s_load_names
list(g_load = g_load, s_load = s_load, u2 = u2,
map = .rel_map(s_load, factor_corres, type),
cormat = cormat, var_names = var_names, fac_names = fac_names)
}
# Adapter: normalize manually supplied SL/bifactor components (g_load, s_load, u2) to
# the reliability spec. Mirrors the model = NULL branch of .OMEGA_FLEX. A cormat may be
# given directly or reconstructed from an oblique pattern matrix and factor
# intercorrelations -- one or the other, not both -- or left NULL, which only
# variance = "sums_load" can then score.
#
# `Phi` describes the loadings it is paired with. Supplied together with `pattern` it belongs
# to that separate oblique solution and is used only to rebuild the correlation matrix, as it
# always has been -- the pairing is what a Schmid-Leiman input needs, where `s_load` holds the
# orthogonalized group loadings and `pattern` the parent oblique ones, two different matrices
# whose model-implied correlations differ once more than three factors make the second-order
# model overidentified. Supplied without a `pattern` there is no other loading matrix for it
# to describe, so it is the factor intercorrelation matrix of `s_load` itself: the components
# then specify a correlated-factors solution, and it enters the model-implied common variance
# as the quadratic form S Phi S'. That is the one way this route can express correlated group
# factors; without it the coefficients would describe an orthogonal model instead, and fall
# short of the ones the same solution gets through `efa_fit()`.
.rel_adapt_manual <- function(g_load, s_load, u2, var_names, factor_corres = NULL,
type = "EFAtools", cormat = NULL, pattern = NULL,
Phi = NULL, fac_names = NULL) {
# unclass() before as.matrix(): a Schmid-Leiman loading table is a matrix already, so
# as.matrix() returns it with its class intact, and the data.frame() the core builds from
# it then keeps it as a single column instead of expanding it -- which fails only later,
# on a names/length mismatch that says nothing about the input.
s_load <- as.matrix(unclass(s_load))
# `cormat` and `pattern` are two ways to give one thing, so taking both is an ambiguous
# request rather than a redundant one. It also cannot be resolved quietly: with a `cormat`
# in hand the reconstruction never runs and `pattern` is read by nothing, yet its presence
# still decides whether `Phi` describes this solution or that one -- an argument with no
# effect of its own silently choosing between two different sets of coefficients. Refuse the
# combination so the caller says which correlation matrix they mean.
if (!is.null(cormat) && !is.null(pattern)) {
cli::cli_abort(
c("{.arg cormat} and {.arg pattern} are two ways to give the same correlation matrix, so supply one or the other.",
"i" = "Drop {.arg pattern} to score the components against {.arg cormat}.",
"i" = "Drop {.arg cormat} to reconstruct one instead, from {.arg pattern} and the {.arg Phi} of the oblique solution it came from."),
class = "efa_reliability_cormat_and_pattern"
)
}
# Whether `Phi` describes this solution's own group factors, rather than a `pattern`'s.
phi_solution <- !is.null(Phi) && is.null(pattern)
# Checked on either reading: paired with a `pattern` it is what the reconstructed
# correlation matrix is built from, and so ends up in every coefficient's denominator,
# which makes it exactly as load-bearing as it is on its own. It has to be the correlation
# matrix of whichever loadings it describes, hence the two column counts.
if (!is.null(Phi)) {
.rel_check_phi(Phi, if (phi_solution) ncol(s_load) else NCOL(pattern))
}
if (phi_solution) {
# A general factor and correlated group factors together do not give the variance
# decomposition these coefficients report: omega hierarchical and omega subscale would
# no longer partition the composite, and the PUC presupposes that two variables of
# different group factors share only the general factor. The core makes the same
# statement in its own terms; refuse the combination here, where it can be named.
if (!isTRUE(all(g_load == 0))) {
cli::cli_abort(
c("{.arg Phi} describes correlated group factors, which cannot be combined with a general factor.",
"i" = "Supply {.arg Phi} for a correlated-factors solution, whose {.arg g_load} is zero throughout.",
"i" = "For a Schmid-Leiman or bifactor solution the group factors are orthogonal; pass {.arg pattern} as well if {.arg Phi} belongs to the oblique solution it came from."),
class = "efa_reliability_phi_with_general"
)
}
}
# A `pattern` alongside `Phi` says the two belong together and `s_load` is a separate,
# orthogonalized matrix -- which is exactly right for a Schmid-Leiman input, and exactly
# wrong for components that carry no general factor, where `s_load` is itself the oblique
# pattern and the factor correlations belong in its common variance. Here `pattern` is
# load-bearing (it is reconstructing the correlation matrix, or the check above would have
# aborted), so the reading cannot be settled from the arguments: a genuine parent oblique
# solution and a `pattern` that merely repeats `s_load` look the same. Take the documented
# one and say what was assumed, since the two give materially different coefficients.
if (!is.null(Phi) && !is.null(pattern) && isTRUE(all(g_load == 0))) {
cli::cli_warn(
c("{.arg Phi} is read as the factor correlations of {.arg pattern}, not of {.arg s_load}.",
"i" = "It is used to reconstruct the correlation matrix only, so the coefficients describe uncorrelated group factors.",
"i" = "Omit {.arg pattern} to score {.arg s_load} as a correlated-factors solution instead."),
class = "efa_reliability_phi_pattern"
)
}
if (is.null(cormat)) {
if (!is.null(Phi) && !is.null(pattern)) {
cormat <- psych::factor.model(f = pattern, Phi = Phi, U2 = FALSE)
}
} else {
# Matched against the loadings' own row names, not `var_names`: here `var_names` are
# the labels the caller wants on the output rows, which need not be the names the
# correlation matrix carries. Unlabelled components are matched by position, as the
# manual contract -- every component in the row order of the loadings -- implies.
cormat <- .rel_check_cormat(cormat, rownames(s_load), nrow(s_load))
}
# The communalities the supplied loadings imply -- the diagonal of Lambda Psi Lambda',
# under the factor correlations where this spec carries them. The uniquenesses are the
# caller's own here, so they are checked against these.
group_h2 <- if (phi_solution) rowSums((s_load %*% Phi) * s_load) else rowSums(s_load^2)
.rel_check_u2(g_load^2 + group_h2, u2)
if (is.null(fac_names)) fac_names <- seq_len(ncol(s_load))
list(g_load = g_load, s_load = s_load, u2 = u2,
map = .rel_map(s_load, factor_corres, type),
Phi = if (phi_solution) Phi else NULL,
cormat = cormat, var_names = var_names, fac_names = fac_names)
}
# Adapter: normalize a lavaan single-factor, correlated-factors, bifactor, or
# second-order solution to the reliability spec, one entry per fitted group. Mirrors the
# structural detection and Schmid-Leiman transform of .OMEGA_LAVAAN (second-order group
# loadings via .sl_group_loadings), and drives the core with variance = "sums_load" (the
# composite variances are model-implied, and are read off the model-implied correlation
# matrix where the fit has residual covariances). Returns a list with, per group, a full spec
# (correlated factors / bifactor / second-order / single factor); a single-factor group is
# additionally flagged `single = TRUE`, since a solution with one factor defines fewer
# coefficients than the core computes for it. A correlated-factors spec is flagged by
# `correlated = TRUE` on the returned list: it carries no general factor, so a front-end
# that reports the general-factor coefficients has to drop them (efa_reliability) or
# refuse the input (OMEGA).
.rel_adapt_lavaan <- function(model, g_name = "g", group_names = NULL) {
.require_lavaan()
if (isFALSE(lavaan::lavInspect(model, what = "converged"))) {
cli::cli_abort("The model did not converge; no reliability coefficients are computed.",
class = "efa_reliability_no_converge")
}
std_sol <- suppressWarnings(lavaan::lavInspect(model, what = "std",
drop.list.single.group = FALSE))
# The model-implied correlation matrix of the variables, per group, fetched on first use
# below and only where a group has residual covariances -- so a fit whose residual
# covariance matrix is diagonal, which is every fit the coefficients were defined on until
# now, asks lavaan for nothing more than it did.
implied_cor <- NULL
if (is.null(group_names)) {
group_names <- names(std_sol)
} else if (length(group_names) != length(std_sol)) {
cli::cli_abort(
c("{.arg group_names} does not match the number of groups in the {.cls lavaan} model.",
"i" = "The model has {length(std_sol)} group{?s}, but {.arg group_names} has {length(group_names)}."),
class = "efa_reliability_group_names"
)
}
tol <- .Machine$double.eps * 100
higherorder <- FALSE
correlated <- FALSE
few_loadings <- FALSE
groups <- vector("list", length(std_sol))
for (i in seq_along(std_sol)) {
lambda <- std_sol[[i]][["lambda"]]
theta <- std_sol[[i]][["theta"]]
psi <- std_sol[[i]][["psi"]]
if (any(is.na(lambda))) {
cli::cli_abort("Some loadings are {.val NA} or {.val NaN}; no reliability coefficients are computed.",
class = "efa_reliability_na_loadings")
}
if (any(diag(theta) <= 0) || any(diag(psi) <= 0)) {
cli::cli_abort("A Heywood case was detected (a variance of 0 or negative); no reliability coefficients are computed.",
class = "efa_reliability_heywood")
}
var_names_i <- rownames(lambda)
# The uniquenesses below are the diagonal of Theta. A composite's model-implied variance
# is 1' (Lambda Psi Lambda' + Theta) 1, so a fit with correlated residuals holds variance
# that the diagonal does not carry (Raykov, 2001). Such a fit takes its composite
# variances from the model-implied correlation matrix instead.
#
# That matrix is read from lavaan, not rebuilt from Theta. The standardized Theta holds
# residual correlations off the diagonal and residual variances on it, so it is not one
# matrix in one metric. For a second-order fit the rebuild would also have to add the
# first-order factor covariances that `beta` carries.
#
# With a diagonal Theta the two expressions are the same quantity. This then stays NULL,
# and the arithmetic of every such solution is unchanged.
implied_i <- if (any(abs(theta[upper.tri(theta)]) > tol, na.rm = TRUE)) {
if (is.null(implied_cor)) {
implied_cor <- lavaan::lavInspect(model, what = "cor.ov",
drop.list.single.group = FALSE)
}
implied_cor[[i]]
}
# A single factor has no group factors: normalize it to the spec such a solution takes on
# every other input route, which the core scores like any of them. Marked `single = TRUE`
# so the front-ends can report the coefficients it defines.
if (ncol(lambda) == 1) {
groups[[i]] <- c(
# Carried beside the spec rather than in its `fac_names`, which the core reads to
# build the group-factor rows this spec has none of.
list(single = TRUE, fac_label = colnames(lambda)),
.rel_single_factor_spec(list(g_load = lambda[, 1],
s_load = lambda[, 0, drop = FALSE],
u2 = diag(theta), cormat = NULL,
implied = implied_i,
var_names = var_names_i)))
next
}
col_names <- colnames(lambda)
# Detect the model type once (all groups share the fixed-zero structure).
if (i == 1) {
# A second-order model routes the covariances of its first-order factors through
# `beta`, which none of the other supported structures has. Read from `beta` rather
# than from an all-zero general-factor column, because that column cannot be found
# at all when the general factor is misnamed -- and such a fit would then pass the
# simple-structure test below and be scored as a set of correlated factors, whose
# `psi` is here the residual covariance matrix of the first-order factors rather
# than their correlation matrix.
beta <- std_sol[[i]][["beta"]]
higherorder <- !is.null(beta) && any(abs(beta) > tol, na.rm = TRUE)
# A variable loading on two or more factors is what a general factor over and above
# the group factors looks like in a loading matrix, and is the structure a bifactor
# model is recognized by. Without one -- and without the second-order structure
# above -- there is no general factor whose variance a composite could be
# decomposed into, and the fit is an ordinary set of correlated factors. Decided
# from the structure rather than from `g_name`, which says nothing on its own: a
# factor may be named "g" without being a general factor, and a general factor need
# not be named "g".
bi_check <- lambda
bi_check[abs(bi_check) > tol] <- 1
correlated <- !higherorder && all(rowSums(bi_check) < 2)
if (isTRUE(higherorder)) {
if (sum(colSums(beta) > 0) > 1) {
cli::cli_abort("The higher-order model has more than two latent strata or more than one second-order factor; only second-order models with one second-order factor are supported.",
class = "efa_reliability_higher_order")
}
} else if (!correlated) {
# A genuine bifactor has each item loading on the general and a group
# factor; flag the borderline case where some item loads on fewer than
# two factors so the front-end can warn the user (it is still scored).
few_loadings <- !all(rowSums(bi_check) > 1)
}
}
# Both structures with a general factor locate it by name; a correlated-factors
# solution has none to find, so it needs no name and reads none.
if (!correlated && !any(col_names %in% g_name)) {
cli::cli_abort(
c("Could not find the specified general-factor name in the lavaan solution.",
"i" = "Please check the spelling.",
"i" = if (!higherorder) {
"Some variables load on two or more factors, so the fit is read as a bifactor solution; a correlated-factors solution, which needs no general factor, has each variable loading on one factor only."
}),
class = "efa_reliability_g_name"
)
}
if (isTRUE(correlated)) {
# An ordinary set of correlated factors: no general factor, the loadings are the
# oblique pattern, and the standardized `psi` is the factor correlation matrix
# belonging to it. The core reads the pair as the model-implied common variance
# Lambda Psi Lambda', exactly as it does for an oblique `efa_fit()` solution; the
# front-end drops the coefficients such a solution does not define.
# `psi` is indexed by name rather than taken whole, as the second-order branch below
# does: the core reads it as the correlations of the columns of `s_load` in their
# order, which the two matrices' shared latent ordering supplies but nothing checks.
# Unlabelled columns, which a fitted model does not have, keep the positional
# reading rather than subscripting `psi` down to nothing.
Phi_i <- if (is.null(col_names)) psi else psi[col_names, col_names, drop = FALSE]
groups[[i]] <- list(single = FALSE, g_load = rep(0, nrow(lambda)),
s_load = lambda, u2 = diag(theta), Phi = Phi_i,
map = abs(lambda) > tol, cormat = NULL,
implied = implied_i,
var_names = var_names_i, fac_names = col_names)
next
}
# The coefficients decompose a composite's variance into a general part and one part
# per group factor, which requires the latent variables to be uncorrelated: a
# bifactor model is fitted with orthogonal factors, and a second-order model routes
# the first-order covariances through `beta`, leaving `psi` diagonal either way.
# `lavaan::cfa()` does not impose that by default, so a "bifactor" model fitted
# without `orthogonal = TRUE` returns correlated factors whose covariances the
# coefficients below would silently drop, as does a second-order model with a freed
# residual covariance between first-order factors. Reject either rather than report
# a decomposition of a model that does not admit one. An NA here is a malformed
# solution rather than a correlation, and is left to the checks above.
if (any(abs(psi[upper.tri(psi)]) > tol, na.rm = TRUE)) {
cli::cli_abort(
c("The factors of the {.cls lavaan} solution are correlated; the coefficients need uncorrelated factors.",
"i" = "A bifactor model needs {.code orthogonal = TRUE}; a second-order model needs no freed covariances between the first-order factors."),
class = "efa_reliability_correlated_factors"
)
}
col_names <- col_names[!col_names %in% g_name]
if (isTRUE(higherorder)) {
# Schmid-Leiman the second-order solution: direct general-factor loadings from
# the first-order loadings times the second-order (beta) loadings, and direct
# group-factor loadings via .sl_group_loadings. The general column is computed
# from the original first-order loadings before they are overwritten.
lambda[, g_name] <- lambda[, col_names] %*% std_sol[[i]][["beta"]][col_names, g_name]
lambda[, col_names] <- .sl_group_loadings(lambda[, col_names], psi, col_names)
}
s_load <- lambda[, col_names, drop = FALSE]
groups[[i]] <- list(single = FALSE, g_load = lambda[, g_name], s_load = s_load,
u2 = diag(theta), map = abs(s_load) > tol, cormat = NULL,
implied = implied_i,
var_names = var_names_i, fac_names = col_names)
}
list(groups = groups, group_names = group_names, variance = "sums_load",
higher_order = higherorder, few_loadings = few_loadings,
correlated = correlated)
}
# Blank the cells of a computed coefficient matrix that a correlated-factors solution
# (one with no general factor) does not define, so the result builder omits them as it
# omits any other undefined coefficient.
#
# The general-factor decomposition is not identified without a general factor: omega
# hierarchical and ECV are structurally zero, and PUC ("percent of uncontaminated
# correlations") presupposes a general factor for the cross-factor correlations to be
# uncontaminated of. On the whole-scale row -- still labelled "g" here, and relabelled
# "total" by .reliability_result() -- the omega subscale and H index are
# further artifacts of the synthetic all-zero general-factor column rather than
# coefficients (the H of an all-zero loading vector is 0, and the whole-scale subscale
# omega does not partition the composite without a general factor). What remains is what
# such a solution does define: whole-scale omega total and alpha, and each group factor's
# congeneric omega, H, and alpha, which stay on their own rows.
.rel_drop_general <- function(x) {
x[, c("hier", "ECV", "PUC")] <- NA
x["g", c("sub", "H")] <- NA
x
}
# The canonical spec of a solution with exactly one factor, or NULL when `spec` does not
# describe one.
#
# One factor is one factor whichever slot it arrived in. An input that names it the general
# factor (a single-factor `lavaan` fit, a one-column bifactor matrix) and one that names it
# the only group factor (a one-factor `efa_fit()` solution, manual components with a zero
# `g_load`) are the same model, so both are rewritten the same way -- the loadings as
# `g_load`, no group-factor columns -- and every route reaches the same coefficients rather
# than one reading of the same solution per input format. Any `Phi` goes with them: the
# correlation matrix of a single factor can only be the 1 x 1 identity, which .rel_check_phi()
# has already established and which says nothing about the solution. The correlation matrix
# `cormat` and the model-implied one `implied` are carried over untouched: both describe the
# variables, which the rewrite does not change.
#
# `fac_names` is emptied here only because the core builds the row labels as
# c("g", fac_names) and this spec has one row; the name the input gave the factor is applied
# to that row afterwards, by .rel_drop_single_factor(), from .rel_single_factor_label().
#
# The core scores the result as it scores every other spec -- its `seq_len(k) + 1` indexing
# and its empty-map guards already cover a spec with no group factors -- so a single factor
# needs no arithmetic of its own. What such a solution does not define is dropped afterwards,
# by .rel_drop_single_factor().
.rel_single_factor_spec <- function(spec) {
s_load <- as.matrix(spec$s_load)
n_g <- if (isTRUE(all(spec$g_load == 0))) 0L else 1L
if (n_g + ncol(s_load) != 1L) return(NULL)
g_load <- if (n_g == 1L) spec$g_load else s_load[, 1]
# No group factors, in the p x 0 shape the core reads as none.
none <- matrix(numeric(0), nrow = length(g_load), ncol = 0)
list(g_load = g_load, s_load = none, u2 = spec$u2, map = none, Phi = NULL,
cormat = spec$cormat, implied = spec$implied, var_names = spec$var_names,
fac_names = character(0))
}
# The row label of a single factor: the name the input gives it, or "F1" where the input gives
# none. A factor supplied as the only group factor is named in `fac_names` -- from the
# solution's loading columns, or from the user's own argument -- and keeps that name, as does
# a `lavaan` factor, which the model syntax always names.
#
# The fallback is the default first-factor label rather than the general-factor "g" that the
# multi-factor solutions use on this row. A single factor is the whole model, not the general
# factor of a bifactor or hierarchical one, which is what "g" would state; a factor supplied
# as the general factor of a one-column matrix carries no name saying otherwise, so it takes
# the neutral label too. That also leaves every route agreeing on the label, and not only on
# the coefficients, wherever the input names the factor nothing.
#
# A name is a name: an empty or missing one is not, and neither is the bare column position
# the manual route falls back to when the caller names no factors (`seq_len(ncol(s_load))`,
# which is 1 for a single factor).
.rel_single_factor_label <- function(fac_names) {
if (length(fac_names) != 1L || is.na(fac_names) || is.numeric(fac_names) ||
!nzchar(as.character(fac_names))) {
return("F1")
}
as.character(fac_names)
}
# Blank the cells of a computed coefficient matrix that a single-factor solution does not
# define, so the result builder omits them as it omits any other undefined coefficient, and
# label its one row with the name the factor came in under (see .rel_single_factor_label).
#
# What one factor leaves is omega total, standardized alpha, and the H index. Alpha assumes
# essentially tau-equivalent items, which is nested in a one-factor model, so this is the one
# solution for which reporting it is defensible rather than merely computable. The others are
# not withheld for want of an estimate but because each would state something the solution
# cannot: omega subscale is the variance due to the group factors, of which there are none;
# omega hierarchical is the same quantity as omega total here, the one factor accounting for
# all of the common variance, and two columns holding the same number invite a reader to
# compare a number with itself; and the ECV and the PUC are 1 by construction, which reads as
# evidence of unidimensionality rather than as the arithmetic of a model with one factor.
.rel_drop_single_factor <- function(x, fac_names = NULL) {
x[, c("hier", "sub", "ECV", "PUC")] <- NA
rownames(x) <- .rel_single_factor_label(fac_names)
x
}
# The single-factor note, stated once for both efa_reliability() paths that reach it (the
# lavaan front-end and the shared spec route), so the two cannot drift apart.
.rel_inform_single_factor <- function() {
cli::cli_inform(
c("i" = "The solution has a single factor; omega total, alpha, and the H index are returned.",
"i" = "Omega subscale needs group factors, omega hierarchical is the same quantity as omega total here, and the ECV and the PUC are 1 by construction, so they are omitted."),
class = "efa_reliability_single_factor"
)
}
# Adapter: normalize an oblique EFA() object to the reliability spec, treating it as
# the correlated-factors model it is. A correlated-factors solution identifies
# whole-scale omega total and each factor's congeneric omega/H, but not the
# hierarchical/bifactor indices (omega hierarchical, ECV, PUC), which require a
# general factor. Rather than manufacture one by a Schmid-Leiman transformation --
# which is underidentified for fewer than three factors, imposes proportionality
# constraints, and biases the general loadings under the cross-loadings EFA solutions
# almost always have (Flora, 2020, Adv. Methods Pract. Psychol. Sci.; Mansolf &
# Reise, 2016, Multivariate Behav. Res.) -- the spec carries a zero general factor,
# the oblique pattern as the group loadings, and Phi. The core then returns the
# whole-scale omega total as 1' L Phi L' 1 / 1'R1, each factor's omega total as the
# Phi-aware common variance of its composite over that composite's observed variance,
# each factor's congeneric omega subscale and H, and omega hierarchical / ECV / PUC 0.
#
# A one-factor solution is the exception to the oblique requirement below, having nothing to
# rotate: `efa_fit()` returns it unrotated, and where a rotation was asked for anyway leaves
# `rot_loadings` a copy of the unrotated loadings and no factor intercorrelations. It is read
# from `unrot_loadings`, which such an object always carries, under the 1 x 1 identity its
# single factor takes -- so the same expressions serve it -- and the front-end then rewrites
# the one-column spec into the single-factor one (.rel_single_factor_spec), as it does for
# every other route that can carry one factor. Refusing it for want of an oblique rotation
# would be the one piece of advice a one-factor solution cannot follow.
.rel_adapt_efa <- function(model, factor_corres = NULL, type = "psych",
cormat = NULL, fac_names = NULL) {
L_unrot <- unclass(model$unrot_loadings)
single <- NCOL(L_unrot) == 1L
if (!single && !("Phi" %in% names(model))) {
cli::cli_abort(
c("{.arg model} is not an oblique EFA solution.",
"i" = "Reliability from an EFA needs correlated factors; refit with an oblique rotation."),
class = "efa_reliability_not_oblique"
)
}
L1 <- if (single) L_unrot else unclass(model$rot_loadings)
Phi <- if (single) diag(1) else model$Phi
# Order the factor columns by number, as SL() does, for stable labels. Only some
# rotations label their loading columns, so the order falls back to the columns'
# own order when the labels are absent or carry no factor number.
n_order <- .sl_factor_order(colnames(L1), ncol(L1))
s_load <- L1[, n_order, drop = FALSE]
Phi <- Phi[n_order, n_order, drop = FALSE]
# Model-implied common variance L Phi L'; its diagonal gives the communalities.
common <- s_load %*% Phi %*% t(s_load)
u2 <- 1 - diag(common)
if (is.null(cormat)) {
cormat <- if (is.matrix(model$orig_R)) model$orig_R
else common + diag(u2, nrow = length(u2))
} else {
cormat <- .rel_check_cormat(cormat, rownames(s_load), nrow(s_load))
}
if (is.null(fac_names)) {
fac_names <- colnames(s_load)
# Unlabelled loading columns still need one row label per group factor.
if (is.null(fac_names)) fac_names <- paste0("F", seq_len(ncol(s_load)))
}
list(g_load = rep(0, nrow(s_load)), s_load = s_load, u2 = u2,
map = .rel_map(s_load, factor_corres, type), Phi = Phi,
cormat = cormat, var_names = rownames(s_load), fac_names = fac_names)
}
# Adapter: normalize a raw bifactor loading matrix (general factor in the first
# column, group factors in the rest) to the reliability spec. Uniquenesses default to
# the orthogonal-bifactor communalities (1 - rowSums(L^2)); the correspondence map
# defaults to the nonzero group-loading pattern (overridable via factor_corres); and,
# when no observed cormat is supplied, the model-implied L L' + diag(u2) is used so
# both variance conventions apply.
.rel_adapt_bifactor <- function(loadings, factor_corres = NULL, u2 = NULL,
cormat = NULL, fac_names = NULL) {
loadings <- as.matrix(loadings)
g_load <- loadings[, 1]
s_load <- loadings[, -1, drop = FALSE]
# Derived uniquenesses complete the loadings to unit variance by construction; only ones
# the caller supplied are worth checking against them.
if (is.null(u2)) {
u2 <- 1 - rowSums(loadings^2)
} else {
.rel_check_u2(rowSums(loadings^2), u2)
}
if (is.null(factor_corres)) {
factor_corres <- abs(s_load) > .Machine$double.eps * 100
} else {
# This adapter does not route through .rel_map(), so a supplied map gets the same
# dimension check and plausibility check here. Only efa_reliability() reaches this
# adapter, so the map is always its `factor_map`.
.rel_assert_map_dim(factor_corres, s_load, arg = "factor_map")
.rel_check_map(s_load, factor_corres, arg = "factor_map")
}
if (is.null(cormat)) {
cormat <- loadings %*% t(loadings) + diag(u2, nrow = length(u2))
} else {
# The loadings' own row names, so an unlabelled matrix is matched by position
# rather than against the V1, V2, ... fallback labels below.
cormat <- .rel_check_cormat(cormat, rownames(loadings), nrow(loadings))
}
var_names <- rownames(loadings)
if (is.null(var_names)) var_names <- paste0("V", seq_len(nrow(loadings)))
if (is.null(fac_names)) {
# A matrix with no group-factor columns holds a single factor, and the only place the
# matrix names it is the general column. Take the name from there, so the label the
# front-end puts on that solution's one row is the one the input gave it -- the same
# label the same solution gets through the components.
fac_names <- if (ncol(s_load) == 0L) colnames(loadings) else colnames(s_load)
if (is.null(fac_names)) fac_names <- seq_len(ncol(s_load))
}
list(g_load = g_load, s_load = s_load, u2 = u2, map = factor_corres,
cormat = cormat, var_names = var_names, fac_names = fac_names)
}
# Canonical map from a `.reliability_core()` output column to its public identity in an
# `efa_reliability` result: the long-format coefficient name, its kind (a reliability
# coefficient versus a common-variance / bifactor index), and the compact label used in
# the printed tables. Columns the core can also produce but that a reliability result does
# not surface (CR, AVE) are intentionally absent, so they never enter the output. The row
# order fixes the coefficient order in the tidy result and the column order in the print.
.reliability_registry <- function() {
data.frame(
core = c("tot", "hier", "sub", "alpha", "H", "ECV", "PUC"),
coefficient = c("omega_total", "omega_hierarchical", "omega_subscale",
"alpha", "H", "ECV", "PUC"),
kind = c("reliability", "reliability", "reliability", "reliability",
"reliability", "common_variance", "common_variance"),
label = c("tot", "hier", "sub", "alpha", "H", "ECV", "PUC"),
stringsAsFactors = FALSE
)
}
# Shape one or more computed coefficient matrices into the tidy `efa_reliability` result:
# a long data.frame `{coefficient, level, factor, group, value}` with a `settings`
# attribute, a `kind` attribute (each surfaced coefficient tagged reliability vs
# common-variance), and class `efa_reliability`. `x` is a single coefficient matrix (one
# unnamed group) or a named list of them (one entry per group); each matrix has factor
# rows (the general factor `"g"` first, then the group factors) and columns from the
# `.reliability_core()` menu. Only registry columns are carried, so the same builder serves
# a full multi-factor matrix and a single-factor `g`-only matrix alike; NA cells (a
# structurally undefined index such as ECV on a group row, or a Heywood / empty-factor
# coefficient the core has already warned about) are dropped, so the result holds only
# realized coefficients. Purely a reshaping helper -- it computes nothing.
.reliability_result <- function(x, settings = NULL) {
if (is.list(x)) {
# A list is always a multigroup result: every group gets a distinct label so the
# print renders one block per group. Unnamed or blank entries fall back to their
# position -- NA is reserved for the single, ungrouped matrix case below.
groups <- x
group_names <- names(x)
if (is.null(group_names)) group_names <- rep("", length(x))
blank <- !nzchar(group_names)
group_names[blank] <- as.character(seq_along(x))[blank]
} else {
groups <- list(x)
group_names <- NA_character_
}
reg <- .reliability_registry()
parts <- lapply(seq_along(groups), function(g) {
mat <- as.matrix(groups[[g]])
factors <- rownames(mat)
if (is.null(factors)) factors <- paste0("F", seq_len(nrow(mat)))
keep <- reg[reg$core %in% colnames(mat), , drop = FALSE]
# A regular (coefficient x factor) grid: each kept coefficient contributes one value
# per factor. Built in one shot -- the columns of `mat[, keep$core]` unroll
# column-major, matching the coefficient-major row order -- so a matrix with no
# surfaced columns yields a 0-row frame rather than a NULL that would break the
# assembly below.
# The general factor is the first row of every matrix the core builds, whatever that row
# is labelled: it is "g" for a solution with group factors, and the factor's own name for
# a single-factor one, which reports that factor and no group factors at all. Read by
# position rather than by that label, which also keeps a group factor a user happens to
# name "g" at level "group". Vectorized over the row index so a matrix with no rows
# yields no levels, matching the empty coefficient and factor columns below.
row_level <- ifelse(seq_along(factors) == 1L, "general", "group")
# A correlated-factors solution has no general factor to put in that row. The core
# still builds it, and it holds the coefficients of the composite of every variable,
# which is what a zero general loading leaves it describing. Label the row for what it
# is: called "g" at level "general" it states a general factor the solution does not
# define, and it would carry the label of a group factor a user names "g" as well.
# Positional for the same reason the level is.
if (isTRUE(settings$no_general) && length(factors) > 0L) {
factors[1L] <- "total"
row_level[1L] <- "total"
}
data.frame(
coefficient = rep(keep$coefficient, each = length(factors)),
level = rep(row_level, times = nrow(keep)),
factor = rep(factors, times = nrow(keep)),
group = rep(group_names[g], length(factors) * nrow(keep)),
value = as.numeric(mat[, keep$core]),
stringsAsFactors = FALSE
)
})
out <- do.call(rbind, parts)
out <- out[!is.na(out$value), , drop = FALSE]
rownames(out) <- NULL
kind <- stats::setNames(reg$kind, reg$coefficient)[unique(out$coefficient)]
attr(out, "settings") <- settings
attr(out, "kind") <- kind
class(out) <- c("efa_reliability", "data.frame")
out
}
# Flexible omega function (e.g. to use with loadings obtained by MacOrtho)------
.OMEGA_FLEX <- function(model = NULL, type = c("EFAtools", "psych"),
factor_corres = NULL,
var_names = NULL, fac_names = NULL, g_load = NULL,
s_load = NULL, u2 = NULL, cormat = NULL, pattern = NULL,
Phi = NULL, variance = c("correlation", "sums_load"),
add_ind = TRUE){
if(inherits(model, "schmid")){
pattern <- model$oblique
Phi <- model$phi
model <- model$sl
s_load_names <- setdiff(colnames(model[, -1]),
c("h2", "u2", "p2", "com"))
var_names <- rownames(model)
g_load <- model[, 1]
s_load <- model[, s_load_names]
u2 <- model[, "u2"]
} else if(inherits(model, "SL")){
# Honour a user-supplied cormat; only fall back to the SL object's stored
# correlation matrix when none was given. Flexible-input SL objects store
# orig_R = NA, in which case cormat stays NULL and the checks below apply.
if(is.null(cormat) && is.matrix(model$orig_R)){
cormat <- model$orig_R
}
model <- model$sl
var_names <- rownames(model)
g_load <- model[, 1]
s_load <- model[, 2:(ncol(model) - 2)]
u2 <- model[, "u2"]
}
# Same general + group factor labels regardless of how s_load was obtained.
factor_names <- c("g", seq_len(ncol(s_load)))
if(variance == "correlation"){
if(is.null(cormat)){
if(is.null(Phi) | is.null(pattern)) {
cli::cli_abort(
c("Specify either {.arg cormat}, or {.arg Phi} and {.arg pattern}.",
"i" = "Alternatively, set {.code variance = \"sums_load\"}."),
class = "efa_omega_need_cormat"
)
} else {
# Create the correlation matrix from the pattern coefficients and factor
# intercorrelations
cormat <- psych::factor.model(f = pattern, Phi = Phi, U2 = FALSE)
}
} else {
# Check if it is a correlation matrix
if(!.is_cormat(cormat)) {
cli::cli_abort(
c("{.arg cormat} is not a correlation matrix.",
"i" = "Check the {.arg cormat} input, supply {.arg Phi} and {.arg pattern} instead, or set {.code variance = \"sums_load\"}."),
class = "efa_omega_not_cormat"
)
}
}
}
# Check if input to factor_corres is correct (g_load is a vector here, so the
# item count is its length, equivalently nrow(s_load)).
checkmate::assert_matrix(factor_corres, null.ok = TRUE, nrows = length(g_load),
ncols = ncol(s_load))
# Create an input dataframe
input <- data.frame(g_load, s_load)
colnames(input) <- factor_names
rownames(input) <- var_names
if(type == "EFAtools" & is.null(factor_corres)){
cli::cli_abort("Specify {.arg factor_corres}, or set {.code type = \"psych\"} to derive variable-to-factor correspondences from the highest group-factor loading per variable.",
class = "efa_omega_need_corres")
}
if(type == "psych"){
if(variance != "correlation"){
cli::cli_warn(
c("{.arg variance} is specified; the value {.val {variance}} is used.",
"i" = "Results may differ from the specified {.arg type}."),
class = "efa_omega_variance_override"
)
}
if(is.null(factor_corres)){
factor_corres <- matrix(0, nrow = nrow(s_load), ncol= ncol(s_load))
for(i in seq_len(nrow(s_load))){
# seq_len(k) + 1, not 2:(k + 1): the latter counts down to c(2, 1) with no group
# factors and selects the general column, as .reliability_core() documents.
factor_corres[i, which.max(abs(input[i, seq_len(ncol(s_load)) + 1L]))] <- 1
}
} else {
cli::cli_warn(
c("{.arg factor_corres} is specified; the supplied variable-to-factor correspondences are used.",
"i" = "To compute correspondences as in psych, leave {.code factor_corres = NULL}."),
class = "efa_omega_corres_override"
)
}
}
# Group-factor row labels: user-supplied names, else the factor names carried by a
# model object, else integer positions for manually specified components.
fac_names_out <- if (!is.null(fac_names)) {
fac_names
} else if (is.null(model)) {
seq_len(ncol(s_load))
} else {
colnames(model)[seq_len(ncol(s_load)) + 1L]
}
# Hand the normalized components to the reliability engine.
spec <- list(g_load = g_load, s_load = s_load, u2 = u2, map = factor_corres,
cormat = cormat, var_names = var_names, fac_names = fac_names_out)
.reliability_core(spec, variance = variance, add_ind = add_ind,
arg = "factor_corres")
}
# Reshape a lavaan reliability solution into OMEGA's legacy output ------------
#
# OMEGA's lavaan path is a thin front-end over the shared reliability machinery:
# the lavaan adapter normalizes the fitted model to a per-group spec (Schmid-
# Leiman transforming a second-order model) and the reliability core scores every
# group, single-factor ones included. This function only reassembles those results
# into OMEGA's historical shapes -- a coefficient matrix per group (unwrapped for a
# single group), a named list for several groups, and a named c(Omega, H) vector
# (or a bare omega) for a single factor -- and emits the user-facing notes about the
# model structure. All reliability math lives in .reliability_core.
#
# A single factor defines standardized alpha as well, which the core computes and
# efa_reliability() surfaces. OMEGA's single-factor output is a named c(Omega, H) vector
# rather than a coefficient matrix, so reporting alpha would change the shape of a
# superseded function's return value and not only its coefficient menu; it keeps the two
# coefficients it has always returned.
.OMEGA_LAVAAN <- function(model = NULL, g_name = "g", group_names = NULL,
add_ind = TRUE){
adapt <- .rel_adapt_lavaan(model, g_name = g_name, group_names = group_names)
# OMEGA's input is a Schmid-Leiman, bifactor, second-order, or single-factor solution,
# each of which has a general factor, and its wide per-factor output reports that
# factor's omega hierarchical, ECV, and PUC. A correlated-factors fit defines none of
# them, so it is refused here rather than scored into a table whose general-factor
# columns would be zeros. efa_reliability() scores it and omits those coefficients.
if (isTRUE(adapt$correlated)) {
cli::cli_abort(
c("The lavaan input is invalid; no reliability coefficients are computed.",
"i" = "Provide a bifactor model, a second-order model, or a single-factor model.",
"i" = "To score a correlated-factors solution, use {.fn efa_reliability}."),
class = "efa_reliability_invalid_lavaan"
)
}
# A second-order general factor was Schmid-Leiman transformed by the adapter.
if (isTRUE(adapt$higher_order)) {
cli::cli_inform(
c("i" = "The specified general factor is a second-order factor; omegas are computed on the Schmid-Leiman transformed second-order solution."),
class = "efa_omega_g_second_order"
)
}
# A supplied "bifactor" in which some item loads on fewer than two factors.
if (isTRUE(adapt$few_loadings)) {
cli::cli_inform(
c("i" = "Some variables have fewer than two loadings; did you enter a bifactor model? Provide a bifactor model, a second-order model, or a single-factor model."),
class = "efa_omega_few_loadings"
)
}
omegas <- vector("list", length(adapt$groups))
informed_single <- FALSE
for (i in seq_along(adapt$groups)) {
grp <- adapt$groups[[i]]
if (isTRUE(grp$single)) {
# A single factor: omega total (and, with add_ind, the H index) only.
if (!informed_single) {
msg <- if (isTRUE(add_ind)) {
"The model contained a single factor; only omega total and the H index are returned."
} else {
"The model contained a single factor; only omega total is returned."
}
cli::cli_inform(c("i" = msg), class = "efa_omega_single_factor")
informed_single <- TRUE
}
# Scored through the core on the spec the adapter normalized it to, as every other
# group is; only the two coefficients OMEGA reports for a single factor are kept.
sf <- unclass(.reliability_core(grp, "sums_load", add_ind = add_ind,
arg = "factor_corres"))
omegas[[i]] <- if (isTRUE(add_ind)) {
stats::setNames(c(sf["g", "tot"], sf["g", "H"]), c("Omega", "H"))
} else {
sf["g", "tot"]
}
} else {
# A multi-factor (bifactor / Schmid-Leiman) group: score through the core,
# storing the unclassed matrix -- the OMEGA class is attached to the whole
# output below, matching the historical list-of-matrices shape. The core
# labels the general-factor row "g"; restore the user's general-factor name.
mat <- unclass(.reliability_core(grp, "sums_load", add_ind = add_ind,
arg = "factor_corres"))
rownames(mat)[1] <- g_name
omegas[[i]] <- mat
}
}
if (length(omegas) > 1) {
names(omegas) <- adapt$group_names
} else {
omegas <- omegas[[1]]
}
class(omegas) <- "OMEGA"
omegas
}
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.