Volume 6 · fourteen days

Every analysis so far assumed your scale works.

A mean of seven items treats them as interchangeable, equally related to one construct, and equally interpreted by every group you compare. Those are testable claims, and testing them is psychometrics. This volume runs from item analysis and reliability beyond alpha, through factor structure and confirmatory models, to measurement invariance, latent regressions, longitudinal SEM and networks.

14
days, items to latent structure
4
assumptions Cronbach's alpha needs
3
invariance steps before any group comparison
Day 53 45 minutes · what a scale score assumes

What a mean scale score silently assumes

When you write rowMeans(items) you assert four things: the items measure one thing, they measure it about equally well, the response options mean the same distances, and they mean the same thing to everyone you compare. Each is testable. If they fail, every model in volumes 4 and 5 inherits the failure — attenuated correlations, confounded group differences, and a construct label that does not match what you measured.

The assumptionIf it failsTested on day
The items measure one constructYour scale score is a blend of two things; correlations with it are uninterpretable.57, 58, 60
Items relate to it about equallyAlpha understates reliability; unweighted means misrepresent the construct.55
Items are not redundant or deadWasted length, inflated alpha, participant fatigue.54, 55
It means the same across groupsA "group difference" may be a measurement difference. Fatal for cross-cultural claims.63
It means the same across time"Change" may be change in interpretation, not in the construct.63, 65
The sentence that fails review
"Depression was measured with a nine-item scale (α = .87)." Alpha is not evidence of unidimensionality, it is not the reliability of your composite unless the items are essentially tau-equivalent, and it says nothing about whether your groups used the scale the same way. The stronger version names the factor structure you confirmed, omega, and the invariance level you established — three extra clauses that take this volume to produce and remove an entire category of reviewer objection.
library(psych); library(tidyverse) bfi <- psych::bfi |> as_tibble() # 2,800 respondents, 25 items, 5 constructs # Before any scoring: look at the items as items. bfi |> select(N1:N5) |> describe() |> select(n, mean, sd, skew, kurtosis) # Reverse-keyed items must be handled BEFORE anything else (volume 2 day 14) keys <- c(N1 = 1, N2 = 1, N3 = 1, N4 = 1, N5 = 1) bfi |> select(N1:N5) |> cor(use = "pairwise") |> round(2)#> n mean sd skew kurtosis #> N1 2778 2.93 1.57 0.37 -1.01 #> N2 2779 3.51 1.53 -0.08 -1.05 #> N3 2789 3.22 1.60 0.15 -1.900 #> N4 2764 3.19 1.57 0.20 -1.22 #> N5 2771 2.97 1.62 0.37 -1.09 #> #> N1 N2 N3 N4 N5 #> N1 1.00 0.66 0.55 0.37 0.31 #> N2 0.66 1.00 0.52 0.34 0.28 #> N5 0.31 0.28 0.35 0.40 1.00 ← N1/N2 near-duplicates; N5 weakly related
Do this now · 15 minutes
For each scale in your dataset, write the four assumptions as four sentences about your own items, and mark which you have evidence for. That list is your day-53-to-63 work plan.
Day 54 50 minutes · which items earn their place

Item analysis: the boring day that saves the scale

Before factor analysis, look at each item's distribution and its relationship to the rest. Three diagnostics find almost every problem: a corrected item-total correlation below about .30 (the item is not measuring the same thing), extreme skew with a floor or ceiling (no variance to work with), and a near-perfect correlation with another item (redundancy that inflates alpha without adding information).

library(psych) neuro_items <- bfi |> select(N1:N5) a <- alpha(neuro_items) a$item.stats[, c("n", "r.drop", "mean", "sd")] # r.drop = corrected item-total a$alpha.drop[, "raw_alpha", drop = FALSE] # alpha if this item were removed # Response distribution per item — floor and ceiling made visible response.frequencies(neuro_items) |> round(2)#> n r.drop mean sd #> N1 2778 0.712 2.93 1.57 #> N2 2779 0.696 3.51 1.53 #> N3 2789 0.664 3.22 1.60 #> N4 2764 0.498 3.19 1.57 #> N5 2771 0.437 2.97 1.62 ← weakest, but still above .30: keep #> #> raw_alpha #> N1 0.784 #> N5 0.831 ← alpha would RISE without N5. That is not sufficient reason #> to drop it; breadth of content matters too.
Do not maximise alpha
Dropping every item that raises alpha when removed converges on a scale of near-identical items — high alpha, narrow construct, poor validity. This is the attenuation paradox: reliability and validity stop moving together. Drop an item when it is substantively off-construct, badly worded, or empirically dead (r.drop below .20, no variance), and keep breadth otherwise. Justify each removal in the paper.
# Item response theory, when you want difficulty as well as discrimination library(mirt) m_irt <- mirt(as.data.frame(neuro_items), model = 1, itemtype = "graded") coef(m_irt, simplify = TRUE, IRTpars = TRUE)$items plot(m_irt, type = "trace") # item characteristic curves plot(m_irt, type = "info") # where on the trait the scale is precise#> a b1 b2 b3 b4 #> N1 2.412 -1.82 -0.914 0.112 1.284 #> N5 1.104 -2.41 -1.102 0.318 1.712 ← low discrimination, wide spacing #> ← test information peaks around +0.5 SD: this scale is most precise slightly #> above average neuroticism, and imprecise at the low end
Do this now · 20 minutes
Run alpha() on each of your scales and tabulate n, r.drop, mean, SD and alpha-if-dropped per item. Flag any item with r.drop under .30, and write a substantive justification before removing anything.
Day 55 55 minutes · the four assumptions

Alpha, omega, and when alpha is simply wrong

Cronbach's alpha estimates the reliability of a composite only if the items are unidimensional, essentially tau-equivalent (equal loadings), have uncorrelated errors, and are continuous. Real scales rarely satisfy the second, often violate the third, and never the fourth for Likert items. Omega relaxes the equal-loadings assumption and is the better default — a two-minute change with a real effect on the number you report.

library(psych) alpha(neuro_items)$total[, c("raw_alpha", "std.alpha", "average_r")] om <- omega(neuro_items, nfactors = 1, plot = FALSE) c(omega_total = om$omega.tot, omega_h = om$omega_h, alpha = om$alpha) # Confidence interval on reliability — nobody reports it and everybody should library(MBESS) ci.reliability(neuro_items, type = "omega", interval.type = "bca", B = 1000)#> raw_alpha std.alpha average_r #> 0.812 0.815 0.468 #> #> omega_total omega_h alpha #> 0.841 0.762 0.812 #> #> $est [1] 0.8412 #> $ci.lower [1] 0.8281 #> $ci.upper [1] 0.8538
CoefficientAssumesUse when
AlphaUnidimensional, equal loadings, uncorrelated errorsReporting convention; always alongside omega.
Omega totalUnidimensional; loadings may differThe sensible default for a scale score.
Omega hierarchicalA general factor plus group factorsMultidimensional scales: how much of the composite is the general factor?
Ordinal alpha / omegaPolychoric correlations for ordered categoriesFewer than five response options.
Test-retest (ICC)Stable trait over the intervalTrait claims; report the interval length.
Split-half / Spearman-BrownTwo parallel halvesTwo-item scales, where alpha is meaningless.
Three real-world rules
Two-item scales: alpha is not interpretable — report the Spearman-Brown coefficient or just the inter-item correlation. Few response options: with three or four categories, use polychoric-based ordinal omega (psych::omega(..., poly = TRUE)) or alpha is biased downwards. Multidimensional scales: a high alpha on a two-factor scale is a warning, not a reassurance — alpha rises with the number of items regardless of structure. Establish dimensionality first (days 57–61), then compute reliability.
Do this now · 25 minutes
Recompute reliability for every scale in your dataset as omega total with a bootstrapped interval, next to your existing alpha. Where they differ by more than .02, your loadings are unequal — note it and report omega.
Day 56 50 minutes · coding, observation, diagnosis

Agreement: choosing the right ICC form, and kappa

If humans coded your data — behavioural observations, open-text responses, clinical ratings — reliability means agreement between raters, and the statistic depends on the design. The common error is reporting "the ICC" without saying which of the six forms, because they can differ by a lot and they answer different questions.

library(psych) ICC(ratings) # rows = targets, columns = raters; prints all six forms#> type ICC F df1 df2 p lower bound upper bound #> Single_raters_absolute ICC1 0.62 5.9 29 60 1.0e-08 0.41 0.78 #> Single_random_raters ICC2 0.64 8.2 29 58 2.1e-10 0.44 0.80 #> Single_fixed_raters ICC3 0.71 8.2 29 58 2.1e-10 0.53 0.84 #> Average_raters_absolute ICC1k 0.83 5.9 29 60 1.0e-08 0.68 0.91 #> Average_random_raters ICC2k 0.84 8.2 29 58 2.1e-10 0.70 0.92 #> Average_fixed_raters ICC3k 0.71 8.2 29 58 2.1e-10 0.53 0.84
Your situationReport
Each target rated by different raters drawn from a poolICC(1) — one-way random
All targets rated by the same raters, who represent a populationICC(2) — two-way random, absolute agreement
All targets rated by the same raters, who are the only raters of interestICC(3) — two-way mixed, consistency
Your analysis uses the MEAN of k ratersThe k-variant (ICC2k / ICC3k) — higher, and correct for a composite
Nominal categories, two ratersCohen's kappa (irr::kappa2), weighted if ordinal
Nominal categories, three or more ratersFleiss' kappa (irr::kappam.fleiss)
library(irr) kappa2(codes[, c("rater1", "rater2")]) # two raters, nominal kappa2(codes[, c("r1", "r2")], weight = "squared") # ordinal categories kappam.fleiss(codes[, c("r1", "r2", "r3")]) # 3+ raters # Percent agreement alone is not enough: kappa corrects for chance agree(codes[, c("rater1", "rater2")])#> Cohen's Kappa for 2 Raters (Weights: unweighted) #> Subjects = 120 Raters = 2 #> Kappa = 0.742 z = 11.8 p-value = 0 #> #> Percentage agreement (Tolerance=0) #> Subjects = 120 Raters = 2 %-agree = 87.5 #> ← 87.5% agreement sounds strong, but with skewed categories much of it #> is chance; kappa = .74 is the honest number
Reporting agreement
Name the form, the number of raters, the number of targets, whether raters were crossed or nested, and the interval — "ICC(2,1) = .64, 95% CI [.44, .80], two raters crossed over 30 targets". Report agreement on the same data your analysis uses (a subset double-coded for reliability is standard: say what proportion). And decide your disagreement-resolution rule in advance: consensus discussion, third rater, or averaging.
Do this now · 20 minutes
If any of your variables were human-coded, run the right ICC form or kappa and write the full reporting sentence. If none were, run ICC() on the example data and read the six rows until you can say what each one asks.
Day 57 55 minutes · the decision that shapes everything

Dimensionality: parallel analysis, not eigenvalues over one

The Kaiser criterion (eigenvalues greater than one) systematically overextracts and should not be used. Parallel analysis compares your eigenvalues against those from random data of the same size — keeping only factors that beat noise — and it is the best-performing simple method available. Run it, look at the scree plot, and then let theory arbitrate, because the statistical answer is often ambiguous by one factor.

library(psych) items <- bfi |> select(A1:O5) |> drop_na() fa.parallel(items, fa = "fa", n.iter = 100, fm = "ml") # simulated + resampled vss(items, n = 8, rotate = "oblimin", fm = "ml") # very simple structure nfactors(items, n = 8) # several criteria at once#> Parallel analysis suggests that the number of factors = 6 #> and the number of components = 5 #> #> Very Simple Structure #> VSS complexity 1 achieves a maximum of 0.58 with 5 factors #> VSS complexity 2 achieves a maximum of 0.72 with 6 factors #> The Velicer MAP achieves a minimum of 0.02 with 5 factors #> BIC achieves a minimum of -1134.2 with 6 factors #> ← five criteria, two answers. Theory says five (the Big Five). Report both.
MethodVerdict
Parallel analysisBest simple default. Beats noise, only mildly over-extracts.
Velicer's MAPGood, slightly conservative. Useful as a second opinion.
Scree plotRead it — the elbow carries information no criterion captures — but it is not decisive alone.
Eigenvalue > 1 (Kaiser)Do not use. Overextracts badly; retained only by habit and SPSS defaults.
% variance explainedNot a criterion for the number of factors. There is no threshold.
EGA (day 66)Network-based dimensionality; strong performance, increasingly reported.
Theory and interpretabilityThe tie-breaker. A sixth factor with two items and no name is not a factor.
How to report the decision
Never report only the number you kept. Report what each criterion suggested, that they disagreed if they did, which solutions you examined, and why you chose the one you did — interpretability and theoretical fit are legitimate reasons when stated openly. A reviewer objects to a hidden decision, not to a reasoned one.
Do this now · 25 minutes
Run fa.parallel() and nfactors() on your item pool and write down every suggested number. Then extract the two most plausible solutions and keep both files — tomorrow you decide between them on interpretability.
Day 58 55 minutes · letting the data speak

EFA: extraction, oblique rotation, and naming factors honestly

Exploratory factor analysis with maximum likelihood extraction and oblique rotation is the default for psychological data, because psychological constructs correlate — orthogonal rotation forces a structure your theory does not claim. Read the loading matrix for simple structure: each item loading clearly on one factor, cross-loadings small, every factor with at least three good items.

library(psych) f5 <- fa(items, nfactors = 5, rotate = "oblimin", fm = "ml") print(f5$loadings, cutoff = 0.30) # suppress noise to see structure f5$Phi |> round(2) # factor correlations — why oblique f5$communality |> round(2) # variance of each item explained fa.diagram(f5)#> Loadings: #> ML2 ML1 ML3 ML5 ML4 #> A1 -0.55 #> A2 0.66 #> A3 0.70 #> C1 0.57 #> C2 0.65 #> E1 0.57 #> N1 0.83 #> N2 0.78 #> O1 0.53 #> #> ML2 ML1 ML3 ML5 ML4 #> ML2 1.00 0.28 -0.21 -0.22 0.19 #> ML1 0.28 1.00 0.27 -0.18 0.11 #> ← factors correlate up to .28: orthogonal rotation would have been wrong
ChoiceWhat to pick
Extractionfm = "ml" (maximum likelihood) or "minres". Both fine; ML gives fit statistics.
Rotation"oblimin" or "promax" (oblique). Use "varimax" only if you truly claim uncorrelated factors.
Correlation typePolychoric (cor = "poly") with fewer than five response options; Pearson otherwise.
Loading cutoff for reportingReport all loadings; suppress below .30 for readability and say you did.
Cross-loadingsAn item loading above .30 on two factors is ambiguous — flag it, consider dropping, never hide it.
Minimum items per factorThree well-loading items. Two is unstable; one is not a factor.
Naming factors, honestly
A factor's name is a hypothesis about what its items share, and it is the most over-claimed object in psychometrics. Name it from the items that actually load — not from the questionnaire's marketing, not from the construct you hoped to find. If the three highest loadings are all reverse-keyed items, you may have found a method factor rather than a construct. Write the name, then show a colleague the items without it and see what they call it.
Do this now · 25 minutes
Run your two candidate solutions with oblique rotation, print loadings with a .30 cutoff, and name every factor from its items. Count cross-loadings and items with communalities under .20. Choose the solution you can defend in a sentence, and record the reason.
Day 59 40 minutes · a short, important day

Why reviewers catch "PCA to identify factors"

Principal components analysis and factor analysis answer different questions. PCA finds weighted composites that maximise explained variance — a data-reduction technique with no measurement model and no error term. EFA posits latent causes producing the items, separating common variance from item-specific error. If your claim is about a construct, PCA is the wrong tool, and the difference shows up in the loadings.

library(psych) p5 <- principal(items, nfactors = 5, rotate = "oblimin") f5 <- fa(items, nfactors = 5, rotate = "oblimin", fm = "ml") # The same items, side by side cbind(PCA = round(p5$loadings[1:6, 1], 2), EFA = round(f5$loadings[1:6, 1], 2)) c(pca_var = sum(p5$values[1:5]) / length(p5$values), efa_communality_mean = mean(f5$communality))#> PCA EFA #> A1 -0.61 -0.55 #> A2 0.71 0.66 #> A3 0.74 0.70 #> C1 0.62 0.57 #> ← PCA loadings are systematically larger: components absorb item-specific #> variance that EFA correctly assigns to error
PCAEFA
QuestionCan I summarise these variables in fewer?What latent variables produce these items?
Error termNone. Components are exact composites.Item-specific variance is modelled and removed.
DirectionItems → componentsFactors → items
LoadingsInflated relative to EFACommon-variance only
Legitimate useData reduction, collinearity, image compression, control variablesMeasurement, scale development, construct claims
Reports as"We reduced 40 physiological channels to 5 components""A five-factor structure fitted the item responses"
The one-line rule
If the word "construct", "latent" or "measures" appears in your sentence, use EFA or CFA. If the sentence is "we needed fewer columns", PCA is fine and honest. The failure mode reviewers flag is a paper that runs principal(), calls the output "factors", and then reports alpha for each — three incompatible steps in one paragraph.
Do this now · 15 minutes
Run principal() and fa() on the same items and compare the loading magnitudes and the variance accounted for. Then check your own draft or supervisor's previous papers for the word "factor" applied to PCA output.
Day 60 60 minutes · testing a structure you predicted

Confirmatory factor analysis: the syntax and the standardised solution

EFA asks what the structure is; CFA tests a structure you specify in advance. lavaan's model syntax is three operators — =~ for "is measured by", ~~ for a covariance, ~ for a regression — and one convention: the first indicator's loading is fixed to 1 to set the latent scale unless you standardise.

library(lavaan) mod <- ' neuro =~ N1 + N2 + N3 + N4 + N5 agree =~ A1 + A2 + A3 + A4 + A5 neuro ~~ agree # oblique: latent covariance estimated ' fit <- cfa(mod, data = bfi, estimator = "MLR", missing = "fiml") summary(fit, fit.measures = TRUE, standardized = TRUE)#> lavaan 0.6-17 ended normally after 31 iterations #> Number of observations 2800 #> Number of missing patterns 38 #> #> Model Test User Model: Test statistic 812.114 df 34 P-value 0.000 #> #> Latent Variables: #> Estimate Std.Err z-value P(>|z|) Std.all #> neuro =~ #> N1 1.000 0.813 #> N2 0.941 0.021 44.812 0.000 0.774 #> N5 0.611 0.024 25.104 0.000 0.482 #> agree =~ #> A1 -0.612 0.031 -19.741 0.000 -0.412 #> #> Covariances: #> neuro ~~ agree -0.284 0.021 -13.522 0.000 -0.231
SyntaxMeaning
f =~ x1 + x2 + x3Latent factor f is measured by these indicators.
f1 ~~ f2Estimate the covariance (correlation, when standardised) between two latents.
x1 ~~ x2Correlated residuals — only with a substantive justification (day 62).
y ~ f1 + f2Structural regression — latent predictors (day 64).
f =~ NA*x1 + x2Free the first loading; combine with std.lv = TRUE to fix the factor variance to 1 instead.
estimator = "MLR"Robust ML: correct SEs and a scaled chi-square under non-normality. A sensible default.
missing = "fiml"Full-information ML — uses all available data instead of deleting cases.
Identification, in practice
A factor needs at least three indicators to be identified on its own (two if it correlates with another factor). Latent scale must be set somehow — by fixing the first loading to 1 (default, so the loading table is in raw units) or by fixing the factor variance to 1 (std.lv = TRUE, so loadings are standardised directly). Read the Std.all column for interpretation: those are the loadings you report, and anything under about .40 deserves a comment.
Do this now · 30 minutes
Specify the factor structure you concluded on day 58 as a lavaan model, fit it with MLR and FIML, and report the standardised loadings. Flag every indicator loading below .40 and decide — in writing — whether to keep it.
Day 61 55 minutes · thresholds, and their limits

Fit: what each index measures, and reporting it without spin

Chi-square tests exact fit and rejects essentially every real model at large n, so approximate fit indices developed. The conventional set is CFI and TLI (comparative, higher is better), RMSEA (parsimony-adjusted misfit, lower is better, with a confidence interval) and SRMR (average residual correlation). Thresholds are rules of thumb from simulations with specific conditions — not laws.

library(lavaan) fitMeasures(fit, c("chisq", "df", "pvalue", "cfi", "tli", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper", "srmr", "aic", "bic")) # Where is the misfit? Look at residual correlations, not just the indices. residuals(fit, type = "cor")$cov |> round(2) lavInspect(fit, "cor.lv") |> round(2)#> chisq df pvalue cfi tli #> 812.114 34.000 0.000 0.912 0.884 #> rmsea rmsea.ci.lower rmsea.ci.upper srmr #> 0.091 0.086 0.097 0.058 #> ← CFI acceptable, TLI marginal, RMSEA poor. Honest verdict: mediocre fit, #> and the residual matrix will say where.
IndexConventionalWhat it actually tells you
Chi-squarep > .05Exact fit. Rejected by trivial misfit at n > 400. Report it; do not rely on it.
CFI≥ .95 good, ≥ .90 acceptableImprovement over a model with no relationships. Sensitive to weak overall correlations.
TLI≥ .95Like CFI, penalised for complexity. Usually slightly lower.
RMSEA≤ .06 good, ≤ .08 acceptableMisfit per degree of freedom. Unreliable with small df and small n — report its CI.
SRMR≤ .08Average standardised residual. The most directly interpretable: how far off are the correlations?
AIC / BICLowerOnly for comparing models on the same data. Not absolute fit.
Reporting without spin
Three failure modes. Cherry-picking: reporting only CFI because RMSEA failed — report all four, always. Threshold theatre: "acceptable fit (CFI = .901)" for a model whose RMSEA is .11 and whose residuals show a clear second factor. Silence about respecification: fitting six models and reporting the last. State how many models you fitted, in what order, and why. A mediocre fit honestly described and diagnosed is publishable; a polished fit with a hidden history is a retraction risk.
Do this now · 25 minutes
Report all six fit measures for your CFA, then print the residual correlation matrix and find the three largest residuals. Write one sentence explaining what the misfit is — that sentence is what day 62 acts on.
Day 62 50 minutes · the overfitting trap

Respecification: defensible and indefensible

Modification indices tell you how much chi-square would drop if you freed a fixed parameter. Following them mechanically is the fastest route to a model that fits this sample and nothing else: simulation work shows MI-driven respecification recovers the true model rarely, and capitalises on sampling noise reliably. The index is a diagnostic tool, not an instruction.

library(lavaan) modindices(fit, sort = TRUE, minimum.value = 10)#> lhs op rhs mi epc sepc.lv sepc.all sepc.nox #> 54 N1 ~~ N2 214.81 0.412 0.412 0.318 0.318 #> 61 A1 ~~ A2 68.12 0.184 0.184 0.142 0.142 #> 33 neuro =~ A4 41.20 -0.211 -0.194 -0.152 -0.152 #> ← N1 and N2 are near-paraphrases ("get angry easily" / "get irritated easily"). #> A correlated residual here has a content justification. The cross-loading #> of A4 on neuro does not.
RespecificationDefensible?
Correlated residual between two items with near-identical wordingYes — state the wording overlap as the justification.
Correlated residuals among reverse-keyed itemsOften yes — a documented method effect. Say so.
Correlated residuals between adjacent items in a long questionnaireSometimes — proximity and carry-over effects are real. Justify.
A cross-loading that the MI suggests and your theory does notNo. This is fitting noise; it changes what the factor means.
Freeing parameters one at a time until CFI passes .95No. This is the textbook overfitting trap.
Dropping an indicator because it lowers fitOnly with a content reason, and report the model both ways.
# If you respecify, do it once, with a reason, and report both models. mod2 <- ' neuro =~ N1 + N2 + N3 + N4 + N5 agree =~ A1 + A2 + A3 + A4 + A5 N1 ~~ N2 # justification: near-identical item wording ' fit2 <- cfa(mod2, data = bfi, estimator = "MLR", missing = "fiml") library(semTools) compareFit(fit, fit2) |> summary() # The only real test of a respecified model: does it hold in new data? # Split-half now; independent replication later.#> ####################### Model Fit Indices ########################### #> chisq df pvalue rmsea cfi tli srmr aic bic #> fit 812.114 34 <.001 0.091 0.912 0.884 0.058 181204 181338 #> fit2 418.204 33 <.001 0.066 0.958 0.942 0.041 180812 180952 #> #> ####################### Nested Model Comparison ###################### #> Df AIC BIC Chisq Chisq diff Df diff Pr(>Chisq) #> fit2 33 ... 418.2 #> fit 34 ... 812.1 393.91 1 <2e-16 ***
The publishable framing
"The initial model showed marginal fit (RMSEA = .091). Inspection of residuals and modification indices indicated substantial shared variance between N1 and N2, whose wording is near-identical. We freed that residual covariance — the single modification we made — which improved fit to RMSEA = .066, CFI = .958. Both models are reported in Table 2; the substantive conclusions are unchanged." That paragraph is transparent, justified and reviewable.
Do this now · 20 minutes
Inspect your modification indices, and for each of the top three write the content justification you would put in the paper — or admit there is none. Make at most one change, and report both models with compareFit().
Day 63 60 minutes · before any group comparison

Invariance: the test your cross-group claim depends on

Comparing latent means or correlations across groups — gender, culture, clinical status, or time points — assumes the measurement works the same way in each. That is tested in a nested sequence: configural (same structure), metric (same loadings), scalar (same intercepts). Without at least metric invariance, a group difference may be a difference in how the scale is understood, not in the construct.

library(lavaan); library(semTools) mod <- 'neuro =~ N1 + N2 + N3 + N4 + N5' conf <- cfa(mod, data = bfi, group = "gender", estimator = "MLR") metr <- cfa(mod, data = bfi, group = "gender", group.equal = "loadings") scal <- cfa(mod, data = bfi, group = "gender", group.equal = c("loadings", "intercepts")) compareFit(conf, metr, scal) |> summary()#> ################### Nested Model Comparison ##################### #> Df Chisq Chisq diff Df diff Pr(>Chisq) #> conf 10 184.21 #> metr 14 192.84 8.63 4 0.071 #> scal 18 241.12 48.28 4 <0.001 *** #> #> ##################### Model Fit Indices ######################### #> cfi rmsea srmr Δcfi Δrmsea #> conf 0.962 0.058 0.031 #> metr 0.961 0.052 0.036 -0.001 -0.006 ← metric holds #> scal 0.948 0.061 0.048 -0.013 +0.009 ← scalar fails: ΔCFI > .01
LevelConstrainsLicenses
ConfiguralSame items on same factorsSaying the construct has the same structure in both groups. Nothing quantitative.
Metric (weak)Loadings equalComparing correlations, regressions and covariances across groups.
Scalar (strong)Loadings and intercepts equalComparing observed or latent MEANS. This is what a t-test between groups needs.
StrictAlso residual variances equalRarely required; comparing observed variances.
PartialOne or two constraints relaxedComparisons are still defensible if most indicators are invariant — report which are not and why.
The decision criterion, and what failure means
Judge by change in fit, not by the chi-square difference test, which rejects trivial non-invariance at large n: ΔCFI > .01 or ΔRMSEA > .015 indicates non-invariance. Here scalar invariance fails, so a raw mean comparison between genders is not licensed. The productive response is not to abandon the comparison but to locate it — partialInvariance() finds the offending intercept — and then either report a partial-invariance model or reframe the finding as differential item functioning, which is itself interesting.
# Which parameter breaks it? partialInvariance(list(fit.configural = conf, fit.loadings = metr, fit.intercepts = scal), type = "intercepts") # Longitudinal invariance: the same logic, with occasions instead of groups. # Constrain loadings and intercepts across waves and correlate the residuals # of each item with itself over time. long_mod <- ' neuro_t1 =~ l1*N1_t1 + l2*N2_t1 + l3*N3_t1 neuro_t2 =~ l1*N1_t2 + l2*N2_t2 + l3*N3_t2 # equal loadings = metric N1_t1 ~~ N1_t2 N2_t1 ~~ N2_t2 N3_t1 ~~ N3_t2 '#> poolest gender.1 gender.2 q p delta.cfi #> N4 3.212 3.418 2.981 ... <.001 0.0121 ← the culprit #> N1 2.930 2.941 2.918 ... .412 0.0004
Do this now · 30 minutes
Run the configural-metric-scalar sequence for the grouping variable your paper compares. Report ΔCFI at each step. If scalar invariance fails, locate the item and write the sentence you would put in the limitations — or reframe the claim.
Day 64 60 minutes · latent variables in regressions

SEM: regressions between latent variables, without measurement error

The payoff of measurement modelling is that structural coefficients between latent variables are corrected for unreliability — the attenuation from volume 4 day 35 disappears, because error is estimated rather than absorbed into the estimate. The cost is sample size, complexity and a fit that must hold for both parts of the model at once.

library(lavaan) sem_mod <- ' # measurement stress =~ s1 + s2 + s3 + s4 support =~ p1 + p2 + p3 depress =~ d1 + d2 + d3 + d4 + d5 # structure depress ~ b1*stress + b2*support support ~ a1*stress # derived indirect := a1*b2 total := b1 + a1*b2 ' fit_sem <- sem(sem_mod, data = d, estimator = "MLR", missing = "fiml", se = "bootstrap", bootstrap = 2000) summary(fit_sem, standardized = TRUE, fit.measures = TRUE, rsquare = TRUE)#> Model Test User Model: Test statistic 214.81 df 51 P-value 0.000 #> CFI 0.961 TLI 0.950 RMSEA 0.052 [0.045, 0.060] SRMR 0.041 #> #> Regressions: #> Estimate Std.Err z-value P(>|z|) Std.all #> depress ~ #> stress (b1) 0.482 0.061 7.902 0.000 0.412 #> support (b2) -0.318 0.058 -5.483 0.000 -0.274 #> support ~ #> stress (a1) -0.412 0.061 -6.754 0.000 -0.388 #> #> Defined Parameters: #> indirect 0.131 0.031 4.226 0.000 0.106 #> total 0.613 0.068 9.012 0.000 0.518 #> #> R-Square: depress 0.412 support 0.151 # Factor scores, when you must export the latent variable to another analysis fs <- lavPredict(fit_sem) # note: scores carry uncertainty that head(fs) # downstream models will ignore # The path diagram — useful for thinking, rarely publication-ready as-is library(semPlot) semPaths(fit_sem, what = "std", layout = "tree2", edge.label.cex = 0.8, sizeMan = 6, sizeLat = 9, residuals = FALSE)
Three practical limits
Sample size. Rules of thumb (5–10 cases per estimated parameter; n ≥ 200 for modest models) are crude — better to run a Monte Carlo power analysis for your specific model (volume 8 day 79 gives the recipe, simsem automates it). Two-step thinking. Establish acceptable measurement fit before adding structural paths; otherwise you cannot tell which part is misfitting. Factor scores. Exporting lavPredict() output into a regression discards the measurement-error correction you just paid for — keep the model latent if you can.
Do this now · 30 minutes
Convert your main volume-4 regression into a latent model: measure each construct with its items, fit the measurement model alone first, then add the structural paths. Compare the latent structural coefficient with your earlier observed-variable estimate — the difference is attenuation.
Day 65 60 minutes · the trait-state question

Cross-lagged panels, and why RI-CLPM replaced them

The classic cross-lagged panel model regresses each variable on both variables at the previous wave and calls the cross-paths evidence of directional influence. The problem: with three or more waves it conflates stable between-person differences with within-person change, so a cross-lagged path can be large and significant when nothing changes within anybody. The random-intercept version separates them, and it has become the expected analysis.

library(lavaan) # Traditional CLPM clpm <- ' x2 ~ a1*x1 + b1*y1 y2 ~ c1*y1 + d1*x1 x3 ~ a1*x2 + b1*y2 y3 ~ c1*y2 + d1*x2 x1 ~~ y1 x2 ~~ y2 x3 ~~ y3 ' fit_clpm <- sem(clpm, data = d, estimator = "MLR", missing = "fiml") # RI-CLPM: stable trait levels split out as random intercepts, # so the lagged paths describe WITHIN-person carry-over. riclpm <- ' # between-person stable components RIx =~ 1*x1 + 1*x2 + 1*x3 RIy =~ 1*y1 + 1*y2 + 1*y3 RIx ~~ RIy # within-person components wx1 =~ 1*x1 wx2 =~ 1*x2 wx3 =~ 1*x3 wy1 =~ 1*y1 wy2 =~ 1*y2 wy3 =~ 1*y3 # within-person dynamics (constrained equal over waves) wx2 ~ a*wx1 + b*wy1 wy2 ~ c*wy1 + d*wx1 wx3 ~ a*wx2 + b*wy2 wy3 ~ c*wy2 + d*wx2 wx1 ~~ wy1 wx2 ~~ wy2 wx3 ~~ wy3 x1 ~~ 0*x1 x2 ~~ 0*x2 x3 ~~ 0*x3 y1 ~~ 0*y1 y2 ~~ 0*y2 y3 ~~ 0*y3 ' fit_ri <- sem(riclpm, data = d, estimator = "MLR", missing = "fiml") summary(fit_ri, standardized = TRUE, fit.measures = TRUE)#> Regressions (within-person): #> Estimate Std.Err z-value P(>|z|) Std.all #> wx2 ~ wy1 (b) -0.041 0.048 -0.854 0.393 -0.038 #> wy2 ~ wx1 (d) 0.182 0.051 3.569 0.000 0.171 #> #> ← the CLPM had reported BOTH cross-paths as significant. Once stable #> between-person differences are separated, only one survives.
ModelUse when
CLPMTwo waves only, or when between-person prediction is genuinely the question. Say which.
RI-CLPMThree or more waves and a within-person hypothesis. The current default.
Latent growth modelThe question is the shape of change, not reciprocal influence. Volume 5 day 50, in SEM form.
Latent change scoreChange between adjacent waves as an explicit latent variable.
Multilevel (lmer)Many unequally spaced occasions — ESM. Volume 5.
Continuous-time (ctsem)Intervals differ a lot between people; lagged effects depend on interval length.
The interval problem, briefly
A cross-lagged coefficient is not a property of the process, it is a property of the process and your measurement interval. The same underlying dynamic yields different lagged coefficients at one week, one month and one year, and effects can even change sign as intervals lengthen. So never compare your lagged coefficients with another study's unless the intervals match, and state the interval whenever you report one.
Do this now · 30 minutes
If you have three or more waves, fit both the CLPM and the RI-CLPM and compare the cross-paths. Write the sentence that describes what changed. If you have two waves, write the limitation paragraph explaining what a two-wave design cannot separate.
Day 66 55 minutes · an alternative to latent causes

Networks: symptoms as an interacting system

Network psychometrics replaces the latent-cause assumption with a different one: that symptoms directly influence each other. Statistically it estimates a regularised partial correlation network — each edge is the association between two variables conditional on all the rest. It is genuinely useful and routinely over-interpreted; stability analysis is what separates the two.

library(bootnet); library(qgraph) net <- estimateNetwork(symptoms, default = "EBICglasso") # regularised, sparse plot(net, layout = "spring", labels = TRUE) centralityPlot(net, include = c("Strength", "ExpectedInfluence"))#> Estimating Network. Using package::function: #> - qgraph::EBICglasso #> - 14 nodes, 91 possible edges, 43 non-zero edges (47% sparsity) # The two analyses without which a network paper is not reviewable boot_case <- bootnet(net, nBoots = 1000, type = "case", nCores = 4) plot(boot_case) # correlation stability of centrality corStability(boot_case) # CS-coefficient: aim for > 0.5 boot_np <- bootnet(net, nBoots = 1000, nCores = 4) plot(boot_np, labels = FALSE, order = "sample") # edge-weight CIs plot(boot_np, "edge", plot = "difference") # which edges truly differ#> === Correlation Stability Analysis === #> Sampling levels tested: 0.1 ... 0.75 #> Strength: 0.592 #> Expected influence: 0.518 #> Closeness: 0.128 ← unstable; do not interpret or report #> Betweenness: 0.048 ← unstable
Four honest caveats
Cross-sectional networks are not causal, and an edge is not a mechanism — it is a conditional association in one sample. Centrality is unstable at typical sample sizes, especially closeness and betweenness; report the CS-coefficient and do not rank nodes without it. Edges are shrunk by regularisation, so absent edges are not evidence of conditional independence. Networks and factor models are often statistically equivalent — choosing one is a theoretical commitment, so argue for it rather than presenting the network as assumption-free.
# EGA: network-based dimensionality, a strong complement to day 57 library(EGAnet) ega <- EGA(symptoms, model = "glasso", plot.EGA = TRUE) ega$n.dim bootEGA(symptoms, iter = 500, type = "resampling") # how stable is that number#> Number of communities (dimensions): 5 #> Median dimensions across 500 bootstraps: 5 (95% CI: 4-5) #> ← converges with the parallel analysis of day 57: good triangulation
Do this now · 25 minutes
Estimate a network on any multi-item dataset, then run both bootstraps. Write down the CS-coefficient for strength and state plainly which centrality indices you are entitled to discuss. Compare EGA's dimension count with your day-57 answer.
Reference

The order these steps must happen in

Every step assumes the previous one passed. Running them out of order is how scales get published that do not measure one thing.

1 · items
Distributions, floor and ceiling, item-total correlations, redundancy. Day 54.
2 · dimensionality
Parallel analysis plus a second criterion, arbitrated by theory. Day 57.
3 · structure
EFA for discovery, CFA for confirmation — ideally in different samples. Days 58, 60.
4 · fit
All four indices, plus the residual matrix. Day 61.
5 · reliability
Omega with an interval, given the structure you established. Day 55.
6 · invariance
Configural, metric, scalar — before any group or time comparison. Day 63.
7 · structure between constructs
Latent regressions, error-corrected. Day 64.
Reference

Sentences you can paste and adapt

Reliability
Internal consistency was adequate, omega total = .84, 95% CI [.83, .85] (alpha = .81). Corrected item-total correlations ranged from .44 to .71.
EFA
Parallel analysis suggested five factors and Velicer's MAP five; a six-factor solution added no interpretable factor. Maximum-likelihood extraction with oblimin rotation yielded five factors with loadings from .43 to .83 and two cross-loadings above .30 (A4, O4).
CFA
The five-factor model fitted acceptably, chi-square(265) = 812.11, p < .001, CFI = .958, TLI = .942, RMSEA = .066 [.062, .070], SRMR = .041, with one residual covariance freed between two near-identically worded items.
Invariance
Metric invariance across gender held (ΔCFI = -.001, ΔRMSEA = -.006), but scalar invariance did not (ΔCFI = -.013). Partial scalar invariance, freeing the N4 intercept, was tenable, so latent means are compared under that model.
Agreement
Two raters independently coded 25% of transcripts; agreement was ICC(2,1) = .64, 95% CI [.44, .80], and disagreements were resolved by discussion.
SEM
The structural model fitted well, CFI = .961, RMSEA = .052 [.045, .060]. Stress predicted depression, beta = .41, and the indirect effect via support was .11, bootstrapped 95% CI [.06, .16] from 2,000 resamples.
Reference

Install these once

install.packages(c( "psych", # describe, alpha, omega, fa, fa.parallel, ICC — the workhorse "lavaan", # CFA, SEM, invariance, growth, RI-CLPM "semTools", # compareFit, measurementInvariance, reliability "semPlot", # path diagrams "MBESS", # confidence intervals for reliability "irr", # kappa2, kappam.fleiss "mirt", # item response theory "qgraph", # network visualisation "bootnet", # network estimation and stability "EGAnet", # exploratory graph analysis "simsem" # Monte Carlo power for SEM ))
Checkpoint

Seven questions before volume 7

{{ quizCounter }}
{{ quizScore }}
{{ quizQ }}
{{ quizFb }}

Next: volume 7

Ten days on posteriors and synthesis: brms with the formula syntax you already know, priors you can defend, convergence diagnostics, Bayes factors and evidence for the null — then a complete metafor meta-analysis from effect-size coding to a publishable forest plot.

Start day 67 →