Bass-Ackwards Hierarchical Structural Analysis
Overview
Implements Goldberg's (2006) doi:10.1016/j.jrp.2006.01.001 bass-ackwards method and modern descendants for hierarchical structural analysis. Extracts solutions from 1 to k factors using principal component analysis (PCA), exploratory factor analysis (EFA), or exploratory structural equation modeling (ESEM) engines. It then describes the hierarchy through the correlations between the factor scores of different levels. It computes them with linear algebra from the scoring weights and the correlation matrix the fit uses, so they are exact for that matrix. Waller (2007) doi:10.1016/j.jrp.2006.08.005 showed, for principal components, that these correlations follow from the correlation matrix alone. Includes the Forbes (2023) doi:10.1037/met0000546 extension for redundancy pruning and all-levels cross-correlations.
Oblique rotation as an option, with partialled edge coefficients and
within-level factor correlations in tidy(). Also fixes to the ESEM engine,
and documentation rewritten in plain English.
Breaking changes
- **ESEM with
cor = "spearman"andmissing = "fiml"now stops with an error.** Before this fix, the fit ran, butx$rheld lavaan's Pearson FIML matrix while the object reportedcor = "spearman". All edges were Pearson under a Spearman label. FIML estimates a multivariate-normal (Pearson) matrix on every engine, so it now requirescor = "pearson"for ESEM, as it already did for PCA and EFA. To keep a Spearman basis, usemissing = "pairwise"or"listwise". The warning for ESEM withcor = "spearman"now says that lavaan fits Pearson covariances of the raw data. Before, it said "Pearson-ML model", which was wrong under WLSMV and ULSMV.
Show all changes in 0.3.0Hide the rest of the changes in 0.3.0
New features
- **Oblique rotation as an option.**
ackwards()gains arotationargument. The default stays"varimax", and default numerical output does not change (PCA and EFAfactor_corgain row and column names, see below). Withengine = "pca"or"efa", it also accepts"oblimin"and"promax". The GPArotation package (now in Suggests) is needed for"oblimin"with these engines, and for"promax"withengine = "efa". Withengine = "esem", it also accepts"oblimin"and"geomin". Under an oblique rotation, each level stores the engine's factor correlation. The scores keep that correlation: EFA and ESEM use the oblique form of the ten Berge weights, and PCA uses the oblique components. Thevariancevalues follow psych's convention for correlated factors. Each edgeris then a total correlation. Primary parents, sign alignment, and the diagram still user, andbetaintidy()gives the partialled coefficient. The fit prints a message that says so, andprint(),summary(), andautoplot()label it.prune()warns that its thresholds were set for varimax.boot_edges()refits each replicate with the object's rotation. If that rotation needs GPArotation, it checks for the package first. A PCA level whose oblique rotation fails, or raises an error, ends the hierarchy at the level before it, because PCA rotates once. EFA rotates from several random starts, so its non-convergence warnings are shown and the level is kept. ESEM counts its failed starts, as the first entry under Bug fixes describes. An EFA level whose final rotation step failed still ends the hierarchy. If the installed psych rotates from one start, any EFA rotation warning ends it too. Setseedto reproduce an oblique EFA fit or any ESEM fit exactly. A new test checks the oblique results against Forbes's reference implementation on her three simulation studies. - **Within-level factor correlations are carried and shown.** Every engine now stores the factor correlation it reports for each level. That matrix is permuted and sign-flipped in step with the loadings, in place of a fixed identity matrix. The new table
tidy(x, what = "factor_cor")has one row per pair of factors within a level. Its columns arelevel,factor_a,factor_b, andcor. Thesummary()output prints a "Within-level factor correlations" block only when some pair is correlated, which means a within-level correlation above 1e-8 in size. Under the default varimax rotation every within-level correlation is 0, so no number changes and the block is absent. - **Partialled edge columns.** The edge table from
tidy()gainsbetabesider, and the variance table gainsr2. Thebetacolumn is the standardized regression weight of the child factor on all factors of the parent level together. It removes the part ofrthat the other factors at the parent level share. Ther2column is the share of a factor's score variance that all factors of the level just above account for together. It isNAat level 1. Under varimaxbetaequalsr, andr2equals the sum of that factor's squaredrvalues from the level above. The two pairs of quantities come apart only when the factors within a level are correlated. If a level's within-level score correlation cannot be inverted, both areNA, with a warning. tidy(what = "edges")now warns when the within-level score correlation of a level is nearly singular, meaning its smallest eigenvalue is below1e-2. The factors at that level are then close to collinear, so the partialledbetafor edges from that level is unstable.betais still reported, and the warning names the level. The package chose this cutoff as a numerical guard. It is not a published rule.tidy(what = "variance")andsummary()do not raise this warning.- PCA and EFA
factor_cormatrices now carry the level's factor labels as row and column names, as the ESEM ones already did.
Bug fixes
- **ESEM now counts failed rotation starts.** At each rotated level, lavaan keeps the best of 30 random starts (its default). In 0.2.0, ESEM showed no rotation warning, and it kept the level even when no start converged. Now a warning gives the count of starts that did not converge (for example, "3 of 30"), and the level is kept. If no start converged, the kept rotation did not converge either. The hierarchy then ends at the level before it with a warning. This holds under every ESEM rotation, varimax included. Fits where at least one start converges keep the same levels and values as before.
- **ESEM with ULSMV on continuous items now fits under the default
missing = "pairwise".** Before this fix,ackwards()stopped at level 1 with a lavaan error, complete data included. lavaan 0.7.2efa()fails for ULSMV on continuous items when it uses all available cases. That setting now fits the complete rows, as ML and MLR do, and the edges still come from the pairwise correlation matrix. ULSMV oncor = "polychoric"and WLSMV are unchanged. The help formissinginackwards()now says which estimators use all available cases. It no longer calls WLSMV and ULSMV ordinal-only, and it names the$metafields that record the setting. The estimator table in the engines article no longer calls them ordinal-only either.
Documentation
- **Which matrix an edge is exact for.** Each between-level edge is implied by the correlation matrix the fit uses, and it is exact for that matrix. The package description, the README, the help for
ackwards(),tidy(), andprune(), and five articles now say so. The five articles are the introduction, engines, ordinal, recommended-workflow, and Forbes articles. Scores computed from the observed items do not in general reproduce the edges when that matrix is polychoric or Spearman. The same holds when the matrix comes from incomplete data, by pairwise deletion or by full-information maximum likelihood (FIML). Theackwards()help names the two ESEM settings in which lavaan fits a matrix other than the storedx$r. Withcor = "spearman"andmissing = "pairwise"or"listwise", lavaan fits Pearson covariances of the raw data. Withcor = "pearson"andmissing = "pairwise"on data with missing values, ML and MLR fit the complete rows, and WLSMV fits the pairwise covariances. The package description no longer says that edges can come from materialized scores, because no exported function computes them that way. It credits Waller (2007) with the closed-form result for principal components only. No code or result changed. - **Where the redundancy chase stops, in the
prune()help.** Under the defaultredundancy_criterion = "direct", the chase stops at the first ancestor level that fails the criterion. A level further up that passes again does not restart it. The help now says so, and says that this stop follows Forbes's (2023) reference code,ChaseCorrPaths. To bridge such a gap by your own judgment, pass the factor to drop tomanual. A factor that no rule flagged then getsprune_reason = "manual", andmanualadds no row tox$prune$chains. A new test shows this on componentm7f2of theforbes2023example. The behavior and results ofprune()did not change. - **Varimax and oblimin side by side in the engines article.** The section on orthogonal and oblique rotation now fits EFA to the BFI-25 twice, with varimax and with oblimin, in place of the simulated example. It shows the oblimin factor correlations within each level. It matches each oblimin factor to a varimax factor by Tucker's congruence of loadings. After the match, the two primary-parent trees are the same. It lists the secondary edges that reach the display cutoff under oblimin, with
rbesidebeta, and draws both diagrams. No package function or its output changed. - **Plain-English documentation.** The README, the package description, the help pages, and all nine articles were revised so that a reader outside the field can follow them on one read. Sentences are shorter, and each statistical term is explained in plain words where it is first used. No function, argument, default, or example changed. The code in the articles and the results they show did not change either. The settings that reproduce Forbes (2023), the Pearson default with its ordinal-data warning, and the descriptive reading of the hierarchy are stated as before.
From NEWS.md in the release
Install
Health
ERRORtestsr-oldrel-windows-x86_64
Running 'testthat.R' [264s]
Running the tests in 'tests/testthat.R' failed.
Complete output:
> library(testthat)
> library(ackwards)
>
> test_check("ackwards")
Starting 2 test processes.
> test-suggest_k.R: i Running parallel analysis (5 iterations, PC + FA)...
> test-suggest_k.R: i PA-PC suggested 6 components -- above the evaluated ceiling (`k_max` = 3); reporting k <= 3.
> test-suggest_k.R: i Increase `k_max` to evaluate the full suggestion.
> test-suggest_k.R: i PA-FA suggested 6 factors -- above the evaluated ceiling (`k_max` = 3); reporting k <= 3.
> test-suggest_k.R: i Increase `k_max` to evaluate the full suggestion.
> test-suggest_k.R: v Running parallel analysis (5 iterations, PC + FA)... [506ms]
> test-suggest_k.R:
> test-suggest_k.R: i Running MAP and VSS...
> test-suggest_k.R: CD: 364 rows with missing values removed (2436 complete cases used).
> test-suggest_k.R: v Running MAP and VSS... [271ms]
> test-suggest_k.R:
> test-suggest_k.R:
...[truncated]...
de -1073741819
Backtrace:
▆
1. └─testthat::test_check("ackwards")
2. └─testthat::test_dir(...)
3. └─testthat:::test_files(...)
4. └─testthat:::test_files_parallel(...)
5. ├─withr::with_dir(...)
6. │ └─base::force(code)
7. ├─testthat::with_reporter(...)
8. │ └─base::tryCatch(...)
9. │ └─base (local) tryCatchList(expr, classes, parentenv, handlers)
10. │ └─base (local) tryCatchOne(expr, names, parentenv, handlers[[1L]])
11. │ └─base (local) doTryCatch(return(expr), name, parentenv, handler)
12. └─testthat:::parallel_event_loop_chunky(queue, reporters, ".")
13. └─queue$poll(Inf)
14. └─base::lapply(...)
15. └─testthat (local) FUN(X[[i]], ...)
16. └─private$handle_error(msg, i)
17. └─cli::cli_abort(...)
18. └─rlang::abort(...)
Execution halted- ERROR12 OK, 1 ERROR2026-10-21
- OK13 OK2026-10-17
- ERROR12 OK, 1 ERROR2026-09-24
- OK13 OK2026-09-09
- ERROR12 OK, 1 ERROR2026-09-06
Show 5 earlier changesHide 5 earlier changes
- OK13 OK2026-08-05
- ERROR11 OK, 1 NOTE, 1 ERROR2026-08-04
- NOTE12 OK, 1 NOTE2026-08-01
- OK11 OK2026-07-28
- ERROR5 OK, 1 ERROR2026-07-25
Documentation
- Help pages
- 33 (2 internal)
- Examples that run
- 74%17 run in a plain check · 6 only when donttest examples are enabled (R CMD check --as-cran)
- Exported functions with examples
- 86%
- Documented parameters
- 100%
- Return-value docs
- 93%
- Help pages citing references
- 21%
- Choosing an Engine: PCA, EFA, and ESEM HTML
- Choosing k: How Many Factors? HTML
- Customizing the Hierarchy Diagram HTML
- Interpreting and Labeling Factors HTML
- Introduction to Bass-Ackwards Analysis HTML
- Ordinal Data: Polychoric Correlations and WLSMV HTML
- Replicability-Gated Hierarchies: A Recommended Workflow HTML
- Reproducing Forbes (2023): The AMH Applied Example HTML
- The Forbes Extension: Skip-Level Connections and Pruning HTML
Downloads
Repository
- PRs merged (all time)
- 100%
- Median days to close, 50 most recently updated closed issues
- 2
- Median days to close, 50 most recently updated closed PRs
- 0
Stars over time
Forks over time
Releases over time
Issues over time
Commits over time
10 development-tooling and community-health practices detected across 6 families in the upstream repository
Checks run against github.com/jmgirard/ackwards on 2026-10-18.
The R CMD check workflow runs on Linux, macOS and Windows, and on R-devel
GitHub Pages site last deployed 2026-10-06 · The repository has a site deploy workflow
pkgdown site last built 2026-10-06, before release 0.3.0 (2026-10-19)
Show all practicesHide 2 practices
This repository shows signs of AI coding tools.
- Claude Codefirst seen 2026-06-25Seen in the repository: CLAUDE.mdNamed in .Rbuildignore: .claude, CLAUDE.mdPull request it wrote: opened by a project memberCommits: at least 319 crediting it in the message
- Claude Sonnet 4.684 commits · Jun 2026
- Claude Opus 4.816 commits · Jun 2026
Some of these counts are a minimum, because the search stopped before the oldest commits.
Dependencies
Installing ackwards adds 6 packages (3 with compiled code, 4.2 MB of source tarballs). Heaviest: GPArotation 1.6 MB, psych 1.1 MB, rlang 766 KB.
Code & Tests
Datasets
People & History
Package metadata
- First published
- 2026-07-24
- Total releases
- 3 / 1 yrs
- License
- MIT + file LICENSE OSI
- Minimum R
- ≥ 4.1.0
- Bundled data
- 233 KB / 3 files
- Download size
- 3.4 MB
Cite
Cite this package
Run in R for the authors' entry:
citation("ackwards")The package provides a citation file, so citation() returns the authors' own entry.
Cite the R Observatory
For a number measured here: a download total, a coverage figure, an archival date.
From data release v2026-10-22, which the citation names so these numbers can be found later. More on citing and the projects behind them.