The classic Goldberg (2006) method computes between-level factor-score correlations only for adjacent levels: 1↔2, 2↔3, 3↔4, and so on. Forbes (2023) extended this in two ways: computing correlations across all level pairs (not just adjacent), and using those extra connections to identify and flag redundant or artifactual factors in the hierarchy.
This vignette covers both extensions: pairs = "all" and prune. It teaches
the concepts on a small didactic dataset; for a full reproduction of the
paper's 155-variable applied example on the bundled forbes2023 dataset, see
vignette("ackwards-forbes2023").
An adjacent-only hierarchy shows you the immediate parent–child relationships. What it cannot tell you is whether a factor at level k is essentially the same construct as a factor several levels up — a sign that the intermediate levels are adding noise rather than resolution.
Consider a factor that appears at k = 2, k = 3, and k = 4 and correlates
0.97 with its counterpart at every adjacent level. The adjacent-only diagram shows three consecutive arrows, each nearly perfect. But nothing in the diagram directly flags that the k = 3 factor is redundant: you could skip straight from k = 2 to k = 4 without losing any information.
Skip-level correlations make this visible by computing the correlation between every pair of levels, not just neighbors.
library(ackwards) bfi <- na.omit(bfi25)
pairs = "all"Adding pairs = "all" extends the edge table from adjacent pairs only to every
combination of levels.
# Classic adjacent-only x_adj <- ackwards(bfi, k_max = 5, cor = "polychoric") # All pairs x_all <- ackwards(bfi, k_max = 5, cor = "polychoric", pairs = "all") # How many edges? nrow(tidy(x_adj, what = "edges")) # adjacent only #> [1] 40 nrow(tidy(x_all, what = "edges")) # all pairs #> [1] 85
With k = 5, the adjacent-only model has 40 edges (1×2 + 2×3 + 3×4 + 4×5). The all-pairs model adds every non-adjacent pair — 1↔3, 1↔4, 1↔5, 2↔4, 2↔5, 3↔5 — for 85 edges total.
The table below keeps the non-adjacent edges (levels more than one apart) with
|r| >= 0.5, strongest first — drawn from tidy(x_all, what = "edges"):
| Strongest skip-level edges (|r| ≥ 0.5, non-adjacent levels) | ||||
| Sorted by |r|; shows at most 12 rows | ||||
| From | To | Level (from) | Level (to) | r |
|---|---|---|---|---|
| m3f2 | m5f2 | 3 | 5 | 0.98 |
| m2f2 | m4f2 | 2 | 4 | 0.97 |
| m2f2 | m5f2 | 2 | 5 | 0.97 |
| m2f1 | m4f1 | 2 | 4 | 0.85 |
| m3f1 | m5f1 | 3 | 5 | 0.82 |
| m1f1 | m3f1 | 1 | 3 | 0.77 |
| m1f1 | m4f1 | 1 | 4 | 0.75 |
| m3f3 | m5f3 | 3 | 5 | 0.73 |
| m2f1 | m5f1 | 2 | 5 | 0.69 |
| m3f3 | m5f5 | 3 | 5 | 0.68 |
| m1f1 | m5f1 | 1 | 5 | 0.61 |
| m3f1 | m5f4 | 3 | 5 | 0.56 |
Several factors connect across two or more levels with correlations above 0.90. m3f2 (level 3, factor 2) correlates 0.98 with m5f2 (level 5, factor 2), jumping two levels. This tells you that m3f2 and m5f2 are essentially the same construct — the intermediate levels are just refinements within a stable dimension.
Reading the strongest edge off a table of many correlations is itself a form of selection. With k = 5 the all-pairs table holds 85 edges, and the maximum of that many correlations is biased upward even when every individual estimate is honest. Treat a "strongest link" claim as descriptive rather than inferential: if it is load-bearing, pre-specify which pair of factors you care about rather than reporting whichever correlation came out largest.
The prune() verb uses the skip-level correlations to automatically flag
factors that may not be adding genuine information to the hierarchy.
prune(x, "redundant")A redundant chain is a sequence of factors connected by near-perfect correlations (|r| ≥ 0.9 by default) across levels. If m2f2 → m3f2 → m4f2 → m5f1 all share r > 0.97, the chain reaches the deepest level, so its bottom node m5f1 — the most specific, best-defined manifestation — is retained and the others (m2f2, m3f2, m4f2) are flagged as redundant: they repeat rather than refine the same dimension. A chain that stops short of the deepest level instead keeps its top node, the broadest manifestation (Forbes, 2023).
By default (redundancy_criterion = "direct") a factor is joined to an
ancestor when their score correlation is high directly — the rule Forbes's
own code uses, and the honest reading of "the same construct" (the two factors
share ≥ 81% of their variance directly). Because correlation is non-transitive,
this can differ from following one high-correlation hop at a time in deep
hierarchies; redundancy_criterion = "adjacent" selects that older, adjacent-hop
behavior. On a shallow hierarchy like this one the two agree.
x_prune <- ackwards(bfi, k_max = 5, cor = "polychoric", pairs = "all") |> prune("redundant") #> ℹ Redundancy pruning (direct criterion, |r| ≥ 0.9) flagged 6 nodes. #> ℹ Nodes are retained in the object; inspect with `x$prune$nodes` and #> `x$prune$chains`.
| Node-level pruning annotation | |||
| 6 of 15 factors flagged as redundant | |||
| Factor | Level | Flagged? | Reason |
|---|---|---|---|
| m1f1 | 1 | FALSE | — |
| m2f1 | 2 | FALSE | — |
| m2f2 | 2 | TRUE | redundant |
| m3f1 | 3 | FALSE | — |
| m3f2 | 3 | TRUE | redundant |
| m3f3 | 3 | FALSE | — |
| m4f1 | 4 | TRUE | redundant |
| m4f2 | 4 | TRUE | redundant |
| m4f3 | 4 | TRUE | redundant |
| m4f4 | 4 | TRUE | redundant |
| m5f1 | 5 | FALSE | — |
| m5f2 | 5 | FALSE | — |
| m5f3 | 5 | FALSE | — |
| m5f4 | 5 | FALSE | — |
| m5f5 | 5 | FALSE | — |
6 factors are flagged as redundant (m2f2, m3f2, m4f1, m4f2, m4f3, m4f4): 1 factor at k = 2, 1 factor at k = 3, the entire k = 4 level. This is a striking finding: for this dataset and this k, the four-factor level adds little beyond what you already know from k = 3 and k = 5.
The flagged factors are not removed from the object — prune() is purely a
diagnostic annotation, not a deletion. You can still inspect their loadings, use
their scores, and include them in the diagram. Pruning flags guide interpretation;
they do not alter the model.
For presentations and publications it is cleaner to omit the flagged factors
entirely and draw direct connections from each retained factor to its single
strongest kept ancestor — even when that ancestor is several levels away. This
is the Forbes (2023) pruned-factor diagram, activated by drop_pruned = TRUE.
Forbes (2023) presents two variants: one with correlation labels on each arrow and one without. The first is useful when the strength of each spanning connection matters to the interpretation; the second is cleaner for presentations. Both are reproduced below using the same publication style — black lines, uniform width, plain line ends, and no legend.
With correlation labels (show_r = TRUE):
autoplot(x_prune, drop_pruned = TRUE, show_r = TRUE, color_pos = "black", color_neg = "black", edge_linewidth = 0.6, show_arrows = FALSE, legend = FALSE )

Without labels (cleaner for slides or when the exact values are not the focus):
autoplot(x_prune, drop_pruned = TRUE, color_pos = "black", color_neg = "black", edge_linewidth = 0.6, show_arrows = FALSE, legend = FALSE )

Level 4 is entirely pruned, leaving a visible gap in the y-axis. The gap is
intentional: it shows which level was removed. Spanning arrows bridge
directly from level 3 factors to level 5 factors (and from level 2 to level 5
where intermediate levels were flagged). In this publication style every drawn
line has the same uniform weight (edge_linewidth = 0.6); only connections
with |r| at or above the display threshold (cut_show, default 0.3) are drawn
at all.
To close the gaps and compact the layout while retaining the original level numbers on the axis:
autoplot(x_prune, drop_pruned = TRUE, compress_levels = TRUE, color_pos = "black", color_neg = "black", edge_linewidth = 0.6, show_arrows = FALSE, legend = FALSE )

The level labels still read "1 factor", "2 factors", "3 factors", "5 factors" so readers know which levels were retained; the uniform vertical spacing makes the diagram easier to read in constrained page layouts.
For further cosmetic customization — colours, node labels, arrowheads, and
more — see vignette("ackwards-visualization").
prune(x, "artifact")An artifact factor is one that looks like a copy of a factor from another level rather than a genuine refinement — its loading pattern closely resembles a factor elsewhere in the hierarchy. Similarity is measured by Tucker's congruence coefficient (φ):
$$\phi(F_a, F_b) = \frac{\sum_i \lambda_{ia}\lambda_{ib}} {\sqrt{\sum_i \lambda_{ia}^2 \cdot \sum_i \lambda_{ib}^2}}$$
φ ranges from −1 to +1, with values above 0.95 conventionally read as near-identical loading patterns regardless of sign (Lorenzo-Seva & ten Berge, 2006).
Unlike "redundant", the artifact mode never flags anything
automatically. Forbes (2023) is explicit that identifying an artifact
requires researcher judgment — automating it would manufacture investigator
degrees of freedom — so prune(x, "artifact") computes and stores the
evidence for you to weigh: Tucker's φ for every cross-level factor pair
in x$prune$phi, plus the structural signals of the next section in
x$prune$structural.
x_art <- ackwards(bfi, k_max = 5, cor = "polychoric", pairs = "all") |> prune("artifact") #> ℹ Artifact mode: Tucker's φ computed for all cross-level factor pairs. #> ℹ Structural signals computed: 2 factors flagged (few_items / orphan / #> split_merge). #> ℹ Inspect `x$prune$phi` and `x$prune$structural`; removal is a researcher #> judgment (Forbes, 2023).
The table to read is x$prune$phi. The natural first cut is the
non-adjacent pairs with the highest |φ| — a deep factor whose loading
pattern nearly duplicates a factor two or more levels up is the classic
candidate:
| Strongest non-adjacent loading congruences | ||||
| Top 8 pairs by |φ|, from x$prune$phi — evidence, not flags | ||||
| From | To | Level (from) | Level (to) | φ |
|---|---|---|---|---|
| m3f2 | m5f2 | 3 | 5 | 0.99 |
| m2f2 | m4f2 | 2 | 4 | 0.98 |
| m2f2 | m5f2 | 2 | 5 | 0.98 |
| m2f1 | m4f1 | 2 | 4 | 0.93 |
| m3f1 | m5f1 | 3 | 5 | 0.92 |
| m1f1 | m3f1 | 1 | 3 | 0.88 |
| m1f1 | m4f1 | 1 | 4 | 0.86 |
| m2f1 | m5f1 | 2 | 5 | 0.85 |
For these data the strongest non-adjacent congruence is |φ| = 0.99. Values near 1 mean the deeper factor recycles an earlier loading pattern; whether that makes it a rotation artifact — or a faithfully persisting construct, which is the redundancy view of the same fact — is a judgment made with the substantive content of the items in view, not a threshold the package applies for you.
The two modes surface different fingerprints of the same underlying question:
"redundant": this factor appears at multiple levels with near-identical
score correlations — it persists unchanged as k increases. Auto-flagged
(with Forbes's retention rule), because the score-correlation chain is a
sharp, replicable criterion."artifact": this factor's loading pattern closely resembles a factor
elsewhere in the hierarchy, or its structure looks under-identified (next
section). Reported only — the call is yours.Congruence (φ) is not the only fingerprint of an artifactual factor. Forbes
(2023, Fig. 2) describes several structural signatures, and
prune(x, "artifact") reports three of them per factor in x$prune$structural:
few_items — the factor is the primary (highest-loading) home for fewer
than min_items items (default 3). A factor anchored by one or two items is
under-identified and often an extraction artifact rather than a replicable
construct.orphan — the factor's strongest correlation to the immediately
neighbouring levels is below orphan_r (default 0.5). It does not connect to
the solutions on either side, so it does not replicate across the hierarchy.split_merge — the factor's primary items were spread across two or more
different parent factors at the level above. Items that were separated at the
coarser solution have been merged under one factor at the finer solution — the
split-then-merge anomaly of Forbes Fig. 2.| Structural artifact signals | ||||
| 2 of 15 factors raise a structural signal | ||||
| Factor | Level | Few items | Orphan | Split/merge |
|---|---|---|---|---|
| m1f1 | 1 | FALSE | FALSE | FALSE |
| m2f1 | 2 | FALSE | FALSE | FALSE |
| m2f2 | 2 | FALSE | FALSE | FALSE |
| m3f1 | 3 | FALSE | FALSE | FALSE |
| m3f2 | 3 | FALSE | FALSE | FALSE |
| m3f3 | 3 | FALSE | FALSE | TRUE |
| m4f1 | 4 | FALSE | FALSE | FALSE |
| m4f2 | 4 | FALSE | FALSE | FALSE |
| m4f3 | 4 | FALSE | FALSE | FALSE |
| m4f4 | 4 | FALSE | FALSE | TRUE |
| m5f1 | 5 | FALSE | FALSE | FALSE |
| m5f2 | 5 | FALSE | FALSE | FALSE |
| m5f3 | 5 | FALSE | FALSE | FALSE |
| m5f4 | 5 | FALSE | FALSE | FALSE |
| m5f5 | 5 | FALSE | FALSE | FALSE |
Like Tucker's φ, these signals are flag-and-report only — prune() never
removes a factor on their basis. Identifying an artifact requires researcher
judgment (Forbes is explicit that this step introduces investigator degrees of
freedom); the signals simply point you to the factors worth a closer look. The
two thresholds, min_items and orphan_r, are arguments to prune().
The redundancy criterion has an adjustable redundancy_r threshold (default
0.90) matching Forbes (2023). The artifact criterion has no auto-flag
threshold — prune(x, "artifact") computes Tucker's φ for researcher
inspection; no factors are auto-flagged.
The redundancy_phi companion criterion. Redundancy can optionally require
that linked factors also share a loading pattern (Tucker's φ above a threshold),
not just a high score correlation. The redundancy_phi argument controls this,
and its default (NULL) auto-resolves based on the engine:
|r| is the correlation between the components themselves and
suffices as the redundancy signal.0.95 (Lorenzo-Seva & ten Berge,
2006). Factor scores are indeterminate — any factor admits infinitely
many score series consistent with the model — which makes an |r|-only rule
too liberal; the loading-congruence guard makes the criterion conservative.
prune() announces this auto-resolution in the console.The examples here use the default PCA engine, so no φ filter is applied. To
disable the φ guard on an EFA/ESEM run, pass redundancy_phi = NA; to set your
own threshold, pass a number in (0, 1].
For the BFI, the result is the same across a wide range of thresholds because the redundant chains all have correlations > 0.97 — the flagging is unambiguous. With your own data you may find borderline cases where the threshold matters:
Because prune() is a cheap, standalone step, checking a few redundancy_r
thresholds does not require refitting ackwards() each time — the already-fit
x_all object is re-pruned directly:
thresholds <- c(0.80, 0.85, 0.90, 0.95) counts <- sapply(thresholds, function(thr) { x <- prune(x_all, "redundant", redundancy_r = thr) sum(tidy(x, what = "nodes")$pruned) }) #> ℹ Redundancy pruning (direct criterion, |r| ≥ 0.8) flagged 9 nodes. #> ℹ Nodes are retained in the object; inspect with `x$prune$nodes` and #> `x$prune$chains`. #> ℹ Redundancy pruning (direct criterion, |r| ≥ 0.85) flagged 8 nodes. #> ℹ Nodes are retained in the object; inspect with `x$prune$nodes` and #> `x$prune$chains`. #> ℹ Redundancy pruning (direct criterion, |r| ≥ 0.9) flagged 6 nodes. #> ℹ Nodes are retained in the object; inspect with `x$prune$nodes` and #> `x$prune$chains`. #> ℹ Redundancy pruning (direct criterion, |r| ≥ 0.95) flagged 6 nodes. #> ℹ Nodes are retained in the object; inspect with `x$prune$nodes` and #> `x$prune$chains`. thr_df <- data.frame(redundancy_r = thresholds, n_flagged = counts)
| Factors flagged redundant at each redundancy_r threshold | |
| redundancy_r | Factors flagged |
|---|---|
| 0.80 | 9 |
| 0.85 | 8 |
| 0.90 | 6 |
| 0.95 | 6 |
For the BFI all thresholds agree: the flagged factors are robustly redundant, not borderline cases. In noisier datasets or smaller samples you will typically see the count increase as you lower the threshold.
The Forbes extension does not change the core bass-ackwards analysis. It enriches it with two questions:
Do any factors persist unchanged across multiple levels? (pairs = "all")
Skip-level correlations near 1.0 indicate stable dimensions that survive
changes in k — exactly the kind of robust construct you want to report.
Are there levels where the factor structure is just reorganizing rather
than genuinely differentiating? (prune(x, "redundant"))
Flagged levels can often be removed from the k range without losing
interpretive content.
A common workflow: fit with pairs = "all" first to examine the full picture,
then pipe the result through prune(x, "redundant") to identify which levels
add the most new information, and use that to guide your focus in reporting.
For where redundancy pruning sits in the full recommended workflow — alongside
a split-half replicability gate on hierarchy depth (comparability()) — see
vignette("ackwards-girard").
Forbes, M. K. (2023). Improving hierarchical models of individual differences: An extension of Goldberg's bass-ackward method. Psychological Methods. https://doi.org/10.1037/met0000546
Goldberg, L. R. (2006). Doing it all Bass-Ackwards: The development of hierarchical factor structures from the top down. Journal of Research in Personality, 40(4), 347–358.
Lorenzo-Seva, U., & ten Berge, J. M. F. (2006). Tucker's congruence coefficient as a meaningful index of factor similarity. Methodology, 2(2), 57–64. https://doi.org/10.1027/1614-2241.2.2.57
Tucker, L. R. (1951). A method for synthesis of factor analysis studies (Personnel Research Section Report No. 984). Department of the Army.
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.