Latent-variable conventions
Part of the numerical-validation suite. The recorded conventions behind confirmatory factor analysis, latent class analysis and item response theory.
Latent class analysis — the v1 conventions
Decided 2026-08-18, before any code, as sub-task 4a of
docs/releases/release_0.19.0.md item 4 (kept private). LCA answers "how
many unobserved subgroups?" for categorical indicators — mixture
modeling the clustering tools cannot do (no fit indices, no membership
probabilities, continuous-only distance). This is an estimation-risk
item (EM over a latent structure): it opens with a spike against the
reference and holds until parity passes. Every convention below was
verified against R poLCA 1.6.0.2 (R 4.5.3) by reading its source and
running its documented examples — not recorded from memory. The pinned
values live in tool/validation/generate_lca_references.R and
test/validation/lca_reference_values.json (sub-task 4i).
The reference and its documented examples
poLCA is the reference implementation; the pinned datasets are its own
documented examples, shipped with the package: values (Goodman's 1974
universalistic/particularistic-values data — 4 dichotomous items, n = 216;
the ?poLCA worked example, fit with 2 classes) and carcinoma
(Agresti's seven-pathologist carcinoma ratings — 7 dichotomous items,
n = 118; the ?carcinoma example, fit with 3 classes, plus 2- and 4-class
fits for the enumeration table). Verified global-optimum log-likelihoods
(high-nrep runs): values 2-class −504.467670, carcinoma 2/3/4-class
−317.256837 / −293.704979 / −289.285849. The carcinoma fit reaches
exact 0/1 boundary estimates — deliberately kept as a pinned example,
since boundary handling is where an EM implementation breaks.
The model and the estimator
- The model: C latent classes over J categorical indicators; class
shares π_c and class-conditional item-category probabilities
ρ_{cjk}; cell probability
Σ_c π_c ∏_j ρ_{c,j,y_j}. Indicator categories are the distinct observed values of each indicator over the analyzed cases (sorted; string or numeric both fine). An indicator with a single observed level errors as data, and one with more than 15 levels errors as data as well ("looks continuous") — LCA over a mistakenly-passed scale variable would silently explode the cell space. - Estimation is plain EM (E-step posteriors, M-step weighted
proportions), converged when the log-likelihood improves by less than
tol(1e-10, poLCA's default) or atmax_iterations(1000, poLCA's default). Listwise deletion of cases incomplete on any indicator (poLCA'sna.rm = TRUEdefault); retaining partial responses (na.rm = FALSE) is a recorded cut. - Random starts:
starts(default 20 — deliberately not poLCA'snrep = 1default, which on a multimodal likelihood silently reports a local optimum; the pins pass because both sides find the global optimum) from random normalized π and ρ draws, keeping the best-LL converged solution. Deterministic by default: a fixed built-in base seed, with aseedoption to explore other starts — running the same command twice must reproduce the tables exactly (the bootstrap precedent; asserted by a test). The initialization scheme need not match poLCA's — what is pinned is the converged optimum. - Boundary probabilities are legitimate MLEs (carcinoma reaches exact 0 and 1): no smoothing, no clamping of estimates; logs are guarded at 1e-300 for evaluation only. A class to which no case is modally assigned is reported with a plain note (fewer classes likely fit better), never silently dropped.
- Class order is descending class share π (ties by first-seen) —
poLCA's own ordering is start-dependent, so this is also the
label-switching alignment rule for every pin: the reference
generator sorts poLCA's classes by descending
P(class)before writing values, and the engine emits classes in the same order.
The fit block (poLCA's definitions, verified from source)
- npar = C·Σ_j(K_j−1) + (C−1); AIC = −2LL + 2·npar; BIC = −2LL + npar·ln N with N the analyzed cases (not cells); residual df = min(N, ∏K_j − 1) − npar — all poLCA's formulas verbatim. The identification pre-flight errors as data when residual df < 0 (poLCA merely warns): more parameters than identifiable information is the exact failure the pre-flight exists to stop.
- aBIC = −2LL + npar·ln((N+2)/24) (Sclove's sample-size-adjusted BIC — the Mplus convention; poLCA does not report it, so its pins are R-computed from poLCA's LL and npar).
- Entropy is the relative entropy of the posterior classification,
1 − Σ_i Σ_c(−p_ic ln p_ic)/(N ln C)(the Mplus convention; near 1 = crisp classes). Undefined at C = 1 (null cell). Pinned R-computed from poLCA's posterior matrix. - G² = 2·Σ freq·ln(freq/fit) over the observed response patterns and X² = Σ(freq − fit)²/fit + (N − Σfit) (the unobserved-cell tail term) — poLCA's exact forms, verified from source.
- Classification quality: the C×C table of average posterior probabilities by modally assigned class (the Mplus AvePP diagnostic), with the assigned-N column.
Saved columns and the tool shape
A dedicated latent_class tool (catalog 105 → 106), engine module
beside the reduction family. classes is the fitted class count; an
optional compare_max fits 1…compare_max-class models under the same
settings and prepends a Class Enumeration table (LL, npar, AIC, BIC,
aBIC, entropy per count) — the enumeration workflow in one journaled
command. save_class / save_posteriors save the modal class (in the
descending-π order) and the C posterior columns — the cluster/propensity
save shape. Recorded finding (4e): the read-only ToolPolicy gates
per tool (mutatesData), and the actual cluster/propensity precedent
leaves save-capable analyses ungated — there is no per-call "block only
the save variant" mechanism to hook into. latent_class follows the
precedent (an analysis, not mutatesData); the cross-cutting gap (a
read-only session can still save columns via any save-capable analysis) is
recorded in the feature bucket as its own deferral rather than patched
ad-hoc for one tool. Closed in 0.21.0 item 1 (2026-08-21): the save
options are declared on the catalog parameter (savesColumns) and the
read-only policy withholds and refuses them per call — the record is
docs/design/advanced_features.md §3 and docs/releases/release_0.21.0.md
item 1 (both kept private).
Weights, design, and scope cuts
- Frequency weights enter the E/M sums and every count (N = Σw in the fit block, df, entropy and the pattern frequencies), keeping the weight ≡ replication identity, asserted by a Rust unit test. poLCA has no weights, so the pins run unweighted. A declared survey design is not covered — the standard ignores-design note (item-1 list unchanged).
- Out of scope for v1 (recorded cuts): covariates on class membership (poLCA's regression arm), ordinal/constrained item models, partial missing-response retention, and bootstrap LR tests for class enumeration (BLRT) — the enumeration table's information criteria are the v1 decision aids.
Item response theory — the v1 conventions
Decided 2026-08-18, before any code, as sub-task 5a of
docs/releases/release_0.19.0.md item 5 (kept private). IRT models each
binary item's probability of a correct/keyed response as a function of a
latent trait θ — item difficulty and discrimination rather than a
single reliability number, plus information curves showing where on
the trait the test measures well. This is the second estimation-risk
item (MML/EM over a latent trait): it opens with a spike against the
references and holds until parity passes. Every convention below was
verified against both reference packages — R ltm 1.2-0 and mirt
1.46.1 (R 4.5.3), the CFA dual-reference rule — on the canonical LSAT
data, not recorded from memory. The pinned values live in
tool/validation/generate_irt_references.R and
test/validation/irt_reference_values.json (sub-task 5h).
The references and their data
The pinned datasets are the two canonical LSAT tables both packages ship:
LSAT6 (Bock & Lieberman's five-item LSAT section 6, n = 1000 — ltm's
LSAT, mirt's LSAT6; the Rasch example) and LSAT7 (five items,
n = 1000 — mirt's LSAT7; the 2PL example in mirt's own
documentation). Verified cross-package agreement on the LSAT7 2PL fit —
log-likelihood −2658.8052 to 5e-5, parameters to ~8e-4, standard errors to
~2e-4 — sets the pin tolerances: each package is pinned separately on
the same cells (the dual-pin shape from item 3) at ~2e-3, tighter where
the evidence allows.
The model and the estimator
- Models: the 2PL,
P(y=1|θ) = logistic(aθ + d), and the Rasch model as its common-discrimination constraint — one shareda, free per-item difficulties, θ ~ N(0,1) fixed in both (the latent-scale identification, named in the output note). This isltm::rasch()'s convention;mirt's Rasch (a ≡ 1, free latent variance σ²) is the same model rescaled — a = σ, b = −d/σ — verified numerically on LSAT6 (σ̂ = √0.572 = 0.756 vsltm's common â = 0.7551) and asserted by transform in the generator. Rasch-as-constrained-2PL is checked explicitly (the 5c row): fixing the 2PL's slopes equal must reproduce the Rasch fit. - Estimation is Bock–Aitkin MML/EM: E-step posteriors over a fixed
quadrature — 61 equally spaced nodes on [−6, 6], standard-normal
weights normalized to sum 1 (
mirt's default fixed-point scheme;ltm's 40-point Gauss–Hermite lands inside the cross-package tolerance regardless, per the evidence above) — M-step per-item weighted logistic Newton (2PL), or difficulty-wise Newton with a shared-slope outer Newton (Rasch). Deterministic starts (a = 1, d = 0): the marginal likelihood is well-behaved for these models and both references start deterministically — no seed, no random starts. Converged when the marginal LL improves by < 1e-8, capped at 500 EM cycles; iterations and convergence reported in the note. - Parameterization: estimated in slope–intercept
(a, d)(the numerically stable form,mirt's internal one); reported in the classical IRT parameterization — Discriminationa, Difficultyb = −d/a— with the parameterization named in the table note (the 5a requirement). SEs come from the observed information (central- difference Hessian of the marginal log-likelihood at the MLE —ltm's approach, andmirt's Oakes SEs agree to ~2e-4), with the difficulty SE by the delta method. Under Rasch the shared discrimination's SE appears on each row (one parameter, one SE — noted). - The fit block: marginal log-likelihood; npar = 2J (2PL) or J + 1 (Rasch); AIC = −2LL + 2·npar and BIC = −2LL + npar·ln N with N the analyzed cases (Σw when weighted).
- Information: item information
I_j(θ) = a_j²·P_j(1−P_j)and test informationI(θ) = Σ_j I_j(θ), tabulated on the θ grid −4 … 4 in steps of 0.1 (81 points) withSE(θ) = 1/√I(θ)alongside the test curve. The curves are line-chart geometry: the engine emits them as (series, θ, value) tables and the chart layer renders them asLineChartData— the shipped multi-series line painter, palette, legend and editor apply from day one with no new painter (the 5d row's "painter layout-helper tests" become spec-builder tests). - Data: binary indicators — each item must show exactly two observed levels over the analyzed cases (sorted; the higher level is the keyed/correct response, stated in the note); anything else errors as data, naming the item. Listwise deletion of incomplete cases. Frequency weights enter the pattern frequencies and every count (the weight ≡ replication identity, asserted by a Rust unit test); the references have no weights, so the pins run unweighted. A declared survey design is not covered — the standard ignores-design note.
Out of scope for v1 (recorded cuts)
- No 3PL — the plan's own cut (guessing-parameter estimation is notoriously fragile); revisit only if asked, with priors.
- No polytomous models (graded response, partial credit), no saved ability (EAP/MAP) scores, no item-fit statistics, and no ICC trace-line plots (the information curves are the v1 figures) — each a natural follow-up, recorded here.
CFA follow-ups — the 0.21.0 conventions
Recorded 2026-08-21, before any code, for
docs/releases/release_0.21.0.md item 4 (kept private): the three
follow-ups the CFA v1 scope line left behind. Each sub-item adds its
subsection here when its row a lands; the pins live in
test/cfa_followups_validation_test.dart
against
cfa_followups_reference_values.json
from
generate_cfa_followups_references.R.
The reference is lavaan (0.7-2 at the time of writing, in the R
validation env) — the package whose conventions CFA v1 was already pinned
against (expected information, divisor N), so every follow-up is measured
against the same yardstick. The fixture is the Holzinger–Swineford data
as lavaan ships it (cfa_followups.csv: x1..x9 byte-identical per value
to the semopy copy in hs1939.csv, plus school, sex, and the ordinal
recodes o1..o9 — each x cut at its own quartiles into four ordered
categories, deterministically), so the multi-group and ordinal sub-items
pin against lavaan's own documented example.
Modification indices (4·MI)
- What is computed. For every fixed parameter of the fitted model —
each cross-loading (an indicator on a factor it does not belong to), each
residual covariance between two indicators, and, under
orthogonal, each factor covariance — the modification index is the 1-df Lagrange multiplier (score) test of freeing that parameter alone:MI = S_k² / V_k, withS_k = −(N/2) ∂F/∂θ_kthe score of the fixed parameter at the restricted ML solution andV_k = I_kk − I_kf I_ff⁻¹ I_fkits expected information conditional on the free parameters (Sörbom 1989). It approximates the χ² drop a refit with that parameter freed would show (the score and likelihood-ratio tests coincide to first order; a Rust test asserts the agreement near the null). The EPC is the expected parameter changeS_k / V_k; the Std. EPC is lavaan'ssepc.all— the completely standardized value: a loading asEPC·√φ_ff / √σ̂_jj(the standardized-loading metric), a residual covariance asEPC / √(θ_i θ_j)(a residual correlation), a factor covariance asEPC / √(φ_rr φ_cc)(a factor correlation). - Information matrix. Expected (Fisher), divisor N — the same matrix the v1 standard errors use, so the MIs, the SEs and lavaan's defaults agree by construction. When it is singular (the v1 "empirically underidentified" signal) the table is emitted with no rows and a note, exactly as the SEs are withheld.
- Non-identified candidates are skipped, not printed. A candidate whose
conditional information
V_kis (numerically) zero cannot be freed identifiably — e.g. a residual covariance within a three-indicator factor of an orthogonal model, where that block is just-identified on its own. lavaan computes these asNAand drops them by default; the engine skips them. The identified candidate sets are the same (54 for the correlated HS model: 18 cross-loadings + 36 residual covariances; 48 for the orthogonal one: 18 + 27 between-factor residual covariances + 3 factor covariances — a Rust test asserts both counts). - Listing floor. The table lists candidates with
MI ≥ mi_minimum, largest first; the default floor is 3.84 (χ²(1) at α = .05, lavaan'sminimum.valueidiom rather than a top-N, so an empty table is itself the answer "no single freed parameter would be significant");0lists every candidate. A just-identified model reports that there is nothing identifiable to free. Each row also carries the 1-df p-value of its MI. - The note warns. Indices are one-at-a-time approximations from the current solution and data-driven; the table's note says a freed parameter needs a substantive reason and that a respecified model is exploratory. The guide repeats it.
- Pins. Ten candidates on HS (the four largest of the correlated model
plus two mid-table ones; the three factor covariances and a between-factor
residual covariance of the orthogonal model), each on MI (tol 2e-3),
EPC and Std. EPC (2e-4), against
lavaan::modindices().
Multi-group invariance (4·MG)
- The model. The same measurement model in every group of a grouping
variable, with a mean structure:
Σ_g = Λ_g Φ_g Λ_gᵀ + Θ_g,μ_g = ν_g + Λ_g α_g. Groups are the distinct non-missing values of the grouping variable in ascending order; cases with a missing group value are excluded (the note counts them); every group needs more complete cases than indicators (the error names the group). The grouping variable may not also be an indicator. - The ladder is lavaan's
group.equalsequence with its parameterization, fitted configural → requested level and compared model-to-model: configural (same pattern, all parameters free; intercepts free per group, latent means fixed at 0 everywhere — so the mean structure is saturated and the χ² equals the covariance-only one), metric (+ loadings equal), scalar (+ intercepts equal; latent means free in groups 2…G, the first group the reference at 0), strict (+ residual variances equal). Factor variances and covariances are free in every group at every level. The default requested level is scalar (the level that licenses latent-mean comparison). Reference-indicator scaling throughout (the first indicator's loading fixed at 1 in every group). - Fit function and χ².
F = Σ_g (n_g/N)·F_gwith the normal-theory discrepancy including the mean term,χ² = N·F = Σ_g n_g F_g;df = G·(p(p+1)/2 + p) − npar. Each group's covariance part reuses the single-group objective onS_g + r rᵀ(the identitytr((S + rrᵀ)Σ⁻¹) = tr(SΣ⁻¹) + rᵀΣ⁻¹r); equality constraints are parameter sharing in one working vector, so the v1 optimizer, parameterization and restart policy carry over (a finite-difference test checks the analytic gradient at every level). - Fit indices, lavaan's multi-group definitions (verified numerically
against
fitMeasures()on HS by school). Baseline: the per-group independence model with saturated means (df_b = G·p(p−1)/2); CFI/TLI as in v1 from those; RMSEA and its 90% CI carry the √G factor (√G·√((χ²−df)/(df·N))); SRMR is the N-weighted mean of the per-group SRMRs with the mean residuals included — each standardized by the observed SD and counted amongp(p+1)/2 + presiduals (lavaan'ssrmr=srmr_bentler; its_nomeantwin is the covariance-only average, recorded but not printed). - Comparison columns. Δχ² and Δdf against the model one level up, with the χ² p-value of the difference (the likelihood-ratio test of the added constraints), ΔCFI and ΔRMSEA. The note states the ΔCFI ≈ .01 rule of thumb (Cheung & Rensvold) as guidance, never as a verdict.
- Standard errors. Expected information summed over groups —
(n_g/2)·tr(Σ_g⁻¹ D_a Σ_g⁻¹ D_b)for the covariance part plusn_g·m_aᵀ Σ_g⁻¹ m_bfor the mean part (m_a = ∂μ_g/∂θ_a) — divisor N, as v1; a shared parameter's SE is reported identically in every group. The standardized loadings are completely standardized within each group. - Tables. "CFA Invariance Tests" (one row per fitted level), then the requested level's per-group "CFA Loadings", "CFA Factor Correlations", "CFA Variances", "CFA Intercepts" and — from scalar on — "CFA Latent Means" (group 1's fixed 0 with no SE). Modification indices are not offered for a multi-group model (errors as data).
- Pins. On HS by
school(lavaangroup.labelset to the ascending order the engine uses, so Grant-White is the reference group): per level χ², df, CFI, TLI, RMSEA (+ CI), SRMR, the baseline χ²; parameters at configural (a free loading per group), metric (the shared loading), scalar (a shared intercept, the latent means of group 2 with their SEs) and strict (a shared residual variance) — see the generator for the exact calls.
Ordinal indicators — WLSMV (4·WLSMV)
- When.
estimator: "wlsmv"treats every indicator as ordinal: integer-coded, each distinct value an ordered category (2 to 12 of them — more reads as continuous and is refused with advice to use ML), listwise over the indicators, frequency-weighted counts. Mixed continuous/ordinal models, multi-group WLSMV and modification indices under WLSMV are not offered (errors as data). lavaan's defaults forordered=are the reference: estimator DWLS, testscaled.shifted, delta parameterization, reference-indicator scaling. - Sample statistics. Thresholds
τ_jc = Φ⁻¹(cumulative proportion)from the weighted margins; polychoric correlations by the two-step ML estimator of thepolychoric_correlationtool (thresholds fixed at the margins, ρ by golden-section ML, the bivariate normal CDF by Simpson quadrature). Both reproduce lavaan'ssampstat(thresholds to 1e-6, polychorics to 2e-4 — quadrature, not convention). - The asymptotic covariance Γ (Muthén 1984), from per-case influence
functions — lavaan's assembly, read from its source. A threshold's
influence is
(1{y ≤ c} − Φ(τ_c)) / φ(τ_c); a polychoric's is its pairwise-likelihood score(∂π_ab/∂ρ)/π_abminus the cross term times the two items' threshold influences (the two-step plug-in correction), divided by the information for ρ — where both the information and the cross term are the outer-product (empirical) forms lavaan uses (A22 = mean(s_ρ²),A21 = mean(s_ρ · s_τ^biv)with the pairwise likelihood's own threshold scores), not the expected information (which lands ~10 % off). Γ is the weighted sample covariance of the influences (Σw·infl·inflᵀ / N: per-case, the covariance of the statistics being Γ/N). Verified entry by entry againstlavInspect(fit, "gamma")— the 36 polychoric diagonals agree to 1e-7, the threshold diagonals and the cross terms to the same order. Only the polychoric block enters the fit, the test and the parameter SEs: thresholds are free and exactly identified, so they drop out ofFand ofU— a consequence of the block structure, not an approximation — while their estimation still reaches Γ through the influence correction. - Estimation. DWLS:
F = Σ_{j<k} (ρ̂_jk − σ_jk)² / Γ_jk,jk, the correlation structureσ = ΛΦΛᵀoff-diagonal; residual variances are1 − (ΛΦΛᵀ)_jj(implied, no SE) under the delta parameterization; Φ Cholesky-parameterized as in v1, minimized by the shared BFGS with the restart policy; a communality above 1 is the Heywood flag. The gradient is analytic (finite-difference test). At lavaan's own estimates the engine'sFequals lavaan's to 1e-8 (the residual correlations agree to 1e-8), so the estimates are the same minimum. - Test statistic.
T = (N−1)·F— lavaan's WLS convention (verified:Σ r²/Γ_kkat lavaan's own estimates times 300 reproduces itschisq51.5368 on the fixture; ML uses N·F), then the scaled-shifted statisticT* = a·T + b,a = √(df/tr((UΓ)²)),b = df − a·tr(UΓ),U = W − WΔ(ΔᵀWΔ)⁻¹ΔᵀW,W = diag(Γ)⁻¹,Δ = ∂σ/∂θat the solution — reproduced to 1e-6 from lavaan's owngamma/wls.v/delta(lavaan'schisq.scaling.factoris1/a). The baseline (independence) model has no free correlation parameter, soU_b = W,tr(U_bΓ) = #pairs, and itsT_b = (N−1)·Σ ρ̂²/Γ_kkis scaled the same way. The p-value is χ²_df ofT*. - Fit indices — the "scaled" family (
cfi.scaled,tli.scaled,rmsea.scaledwith its CI), all fromT*andT*_b; RMSEA and its 90% CI use N − 1 (lavaan's categorical convention, verified to 1e-6: N gives 0.08332, N−1 gives lavaan's 0.08346); SRMR is the RMS of the off-diagonal residual correlations over p(p+1)/2 (the diagonal residuals are zero by construction but counted — lavaan'ssrmrfor categorical data, 0.065641; the off-diagonal-only mean would be 0.073388). The robust variants (cfi.robust,rmsea.robust) are not printed — recorded as a possible follow-up. - Standard errors. Robust sandwich
(ΔᵀWΔ)⁻¹ ΔᵀWΓWΔ (ΔᵀWΔ)⁻¹ / (N − 1)for loadings and Φ (lavaan'sseunder WLSMV — pinned at 1e-3), threshold SEs√(Γ_ττ / (N − 1))(lavaan 0.07739 for o1|t1, reproduced). The standardized loading isλ√φ(unit latent-response variance). - Tables. The four ML tables with the same columns (the Model Fit row
carries
T*; the note gives a, b, T, the baseline), plus "CFA Thresholds" (indicator,t_c (lower | upper category), estimate, SE) and "CFA Polychoric Correlations" (the matrix the model was fitted to). - Pins. On the quartile-cut
o1..o9of the fixture againstlavaan::cfa(..., ordered = o1..o9):T,T*, df, p, CFI, TLI, RMSEA (+ CI), SRMR, the scaled baseline; six loadings with SEs, the six Φ entries with SEs, a factor correlation, five thresholds with SEs, four polychorics — the unit tests pin them from the embedded digits of the same fixture, the gate-4 suite end to end.