Today

  1. Quick SEM/CFA refresher
  2. Observed moderation (simulation) with lm() and lavaan
  3. Why “SEM interactions” (latent interactions) are worth the trouble
  4. The main technical difficulties (what breaks, why)
  5. Options in practice: PI (dblcent) vs LMS/QML (and what to report)

Tip

Measurement-first reminder: validate the measurement model (CFA) before fitting any interaction…or anything in general!

Learning objectives

By the end of this deck, you can:

  • Simulate a simple moderation with latent variables and observed indicators
  • Fit observed moderation via lm() and via lavaan::sem()
  • Explain why latent interactions are harder than observed ones
  • Fit a latent interaction in modsem using:
    • product-indicator (PI) approach: method = "dblcent"
    • distribution-analytic approach: method = "lms" (and know what “qml” is)
  • Probe and visualize the interaction (simple slopes, JN, surface)

Minimal SEM/CFA refresher

Core idea

  • Latent construct \((\xi)\) is measured by indicators \((x_1, x_2, x_3)\)

\[ x_i = \lambda_i\,\xi + \delta_i \]

  • CFA: estimate loadings \((\lambda)\) and measurement error \((\delta)\)
  • SEM: add structural regressions among latent variables

Why measurement modeling matters for moderation

Observed moderation uses measured variables.

If the constructs are measured with error (and they always are):

  • Main effects attenuate
  • Interaction effects attenuate even more

Intuition: the product term is “signal × signal” but also “error × signal” and “error × error”.

Warning

Less precise measurements (higher error) decreases power [NO PUBLICATIONS!]! For interactions, power is already usually very low

Simulation setup

We’ll simulate:

  • Outcome (latent): achievement (Y)
  • Predictor (latent): intelligence (X)
  • Moderator (latent): anxiety (Z)

Structural part (prediction of the outcome):

\[ Y = \beta_X X + \beta_Z Z + \beta_{XZ}(X\cdot Z) + \varepsilon \]

Then we create 3 indicators per construct.

Simulate latent variables

set.seed(123)

# ----------------------
# Design choices
# ----------------------
N <- 500

# Structural effects (interpretable):
# - intelligence helps achievement
# - anxiety hurts achievement
# - anxiety reduces the benefit of intelligence (negative interaction)
b_X  <-  0.40
b_Z  <- -0.30
b_XZ <- -0.20

# Correlation between X and Z (optional)
rho_XZ <- -0.20

# Latent predictors (bivariate normal)
# We'll construct Z with the desired correlation with X
X <- rnorm(N, mean = 0, sd = 1)
E_Z <- rnorm(N, mean = 0, sd = 1)
Z <- rho_XZ * X + sqrt(1 - rho_XZ^2) * E_Z

XZ <- X * Z

# Latent outcome
lin <- b_X * X + b_Z * Z + b_XZ * XZ
var_lin <- var(lin)

# choose residual variance so that Var(Y) ~ 1
var_e <- max(1e-6, 1 - var_lin)
Y <- lin + rnorm(N, mean = 0, sd = sqrt(var_e))

c(var_X = var(X), var_Z = var(Z), var_XZ = var(XZ), var_Y = var(Y))
    var_X     var_Z    var_XZ     var_Y 
0.9462803 1.0389162 0.9744937 1.0134686 

Create observed indicators

We’ll use loadings \((\lambda)\) around .70–.80 (VERY GOOD ITEMS!)

# Loadings (can be different per construct if you want)
lambda_X <- c(.80, .70, .75)
lambda_Z <- c(.80, .70, .75)
lambda_Y <- c(.80, .70, .75)
# For standardized latent variables, choose residual variances so items are ~standardized
# Var(item) ≈ lambda^2 * Var(latent) + theta; with Var(latent)=1, set theta = 1 - lambda^2
theta_X <- 1 - lambda_X^2
theta_Z <- 1 - lambda_Z^2
theta_Y <- 1 - lambda_Y^2
# Intelligence indicators (explicit; no helper function)
int1 <- lambda_X[1] * X + rnorm(N, 0, sqrt(theta_X[1]))
int2 <- lambda_X[2] * X + rnorm(N, 0, sqrt(theta_X[2]))
int3 <- lambda_X[3] * X + rnorm(N, 0, sqrt(theta_X[3]))
# Anxiety indicators
anx1 <- lambda_Z[1] * Z + rnorm(N, 0, sqrt(theta_Z[1]))
anx2 <- lambda_Z[2] * Z + rnorm(N, 0, sqrt(theta_Z[2]))
anx3 <- lambda_Z[3] * Z + rnorm(N, 0, sqrt(theta_Z[3]))
# Achievement indicators
ach1 <- lambda_Y[1] * Y + rnorm(N, 0, sqrt(theta_Y[1]))
ach2 <- lambda_Y[2] * Y + rnorm(N, 0, sqrt(theta_Y[2]))
ach3 <- lambda_Y[3] * Y + rnorm(N, 0, sqrt(theta_Y[3]))

# Data frame of observed items
dat <- data.frame(
  int1 = int1, int2 = int2, int3 = int3,
  anx1 = anx1, anx2 = anx2, anx3 = anx3,
  ach1 = ach1, ach2 = ach2, ach3 = ach3
)
# head(dat)

Observed totals (sum/mean scores)

This is what many researchers do (sometimes without realizing the cost for interactions and their meaning from a latent perspective).

dat$intelligence_total <- rowMeans(dat[, c("int1","int2","int3")])
dat$anxiety_total      <- rowMeans(dat[, c("anx1","anx2","anx3")])
dat$achievement_total  <- rowMeans(dat[, c("ach1","ach2","ach3")])

# Center for interpretability: Z = 0 means "average anxiety"
dat$int_c <- as.numeric(scale(dat$intelligence_total, scale = FALSE))
dat$anx_c <- as.numeric(scale(dat$anxiety_total,      scale = FALSE))

dat$int_x_anx <- dat$int_c * dat$anx_c

summary(dat[, c("intelligence_total","anxiety_total","achievement_total")])
 intelligence_total anxiety_total        achievement_total 
 Min.   :-2.43141   Min.   :-2.6564607   Min.   :-2.46318  
 1st Qu.:-0.46922   1st Qu.:-0.5218372   1st Qu.:-0.53039  
 Median : 0.06996   Median :-0.0005894   Median : 0.05928  
 Mean   : 0.02769   Mean   :-0.0207312   Mean   : 0.07591  
 3rd Qu.: 0.54621   3rd Qu.: 0.5289116   3rd Qu.: 0.62722  
 Max.   : 2.47346   Max.   : 2.3890175   Max.   : 2.84914  

Warning

Sum scores require many assumptions that we rarely test (e.g., equal loadings)

Observed moderation with lm()

fit_lm <- lm(achievement_total ~ int_c * anx_c , data = dat)
#            achievement_total ~ int_c + anx_c + int_x_anx
coef(summary(fit_lm))
               Estimate Std. Error   t value     Pr(>|t|)
(Intercept)  0.04925102 0.03396313  1.450132 1.476537e-01
int_c        0.38302542 0.04295836  8.916203 9.268394e-18
anx_c       -0.21927094 0.04007868 -5.471012 7.110258e-08
int_c:anx_c -0.26707651 0.05344051 -4.997641 8.059989e-07

Interpretation:

  • int_c: effect of intelligence when anxiety is average (0 after centering)
  • anx_c: effect of anxiety when intelligence is average
  • int_x_anx: moderation (how the effect of intelligence changes as anxiety increases)

Observed moderation with lavaan::sem()

Same regression, but estimated with SEM syntax.

library(lavaan)

mod_obs <- '
  achievement_total ~ int_c + anx_c + int_c:anx_c 
# achievement_total ~ int_c + anx_c + int_x_anx
'

fit_obs <- sem(mod_obs, data = dat, meanstructure = TRUE)

pe <- parameterEstimates(fit_obs, standardized = TRUE)
pe[pe$op == "~", c("lhs","op","rhs","est","se","pvalue","std.all")]
                lhs op         rhs    est    se pvalue std.all
1 achievement_total  ~       int_c  0.383 0.043      0   0.350
2 achievement_total  ~       anx_c -0.219 0.040      0  -0.216
3 achievement_total  ~ int_c:anx_c -0.267 0.053      0  -0.196

Important

We can write the interaction part in two ways because the interaction term is actually a new variable given by the multiplication of the two predictors.

Observed interaction is not “wrong”, but…

Observed moderation can be fine when:

  • variables are measured with high reliability
  • you’re comfortable treating totals as error-free
  • your goal is prediction, not latent construct inference

But in psychological measurement:

  • reliability is often moderate
  • measurement error is non-trivial
  • interaction effects are small → easily attenuated
  • measures are bound and not really continuous

SEM interactions and issues

SEM moderation = latent interaction

We want:

\[ \eta_Y = \beta_X\,\xi_X + \beta_Z\,\xi_Z + \beta_{XZ}(\xi_X\xi_Z) + \zeta \]

Problem: \((\xi_X\xi_Z)\) is not observed.

Two main families of solutions:

  1. Product indicators (PI): build observed products (e.g., \((x_i z_j)\)) and model them as indicators of a latent interaction factor.
  2. LMS/QML (distribution-analytic): estimate the interaction directly in the likelihood (no product indicators), but standard global fit indices typically disappear (we move away from covariance estimation).

Tip

modsem has multiple approaches available https://modsem.org/articles/methods.html

The main technical issues

  1. Nonlinearity: interaction makes the model nonlinear

  2. Non-normality: product of normals is not normal

  3. Fit logic changes: especially with LMS (no classical \((\chi^2)\), CFI, RMSEA for the interaction model)

Everything else is a consequence: identification, centering confusion, computational burden, interpretation…but also better estimates, correct modelling…

Difficulty 1 — The product is latent (not observed)

In regression: compute \((XZ)\).

In SEM:

  • X and Z are latent
  • you only have indicators
  • product indicators contain extra error cross-terms

Tip

Product indicator means that we are multiplying each item of a scale with all other items, thus creating many other ‘observed’ variables, which will form a ‘new’ latent interaction measure.

Difficulty 2 — Non-normality of the product

Even if \((X)\) and \((Z)\) are normal, \((XZ)\) is not.

par(mfrow = c(1, 2))
hist(X,  main = "Latent X (normal)")
hist(XZ, main = "Latent X*Z (non-normal)")
par(mfrow = c(1, 1))

Implication: classical normal-theory SEs / tests can misbehave, especially in small N.

Difficulty 3 — Identification + parameter explosion (PI)

If \(X\) has \((p)\) indicators and Z has \((q)\) indicators:

  • “all-pairs” product indicators: \((pq)\)
  • many shared components → correlated residuals are plausible
  • too many free covariances can break identification

Warning

PI is transparent but can become a big model fast.

Difficulty 4 — Fit indices disappear (LMS)

With LMS/QML you typically cannot report classical SEM global fit indices for the interaction model.

So evaluation becomes:

  1. Fit baseline model (no interaction): report CFI/TLI/RMSEA/SRMR there
  2. Add interaction: compare baseline vs interaction using log-likelihood difference test (or AIC/BIC)

This is a conceptual shift: fit ≠ truth, and sometimes fit ≠ available.

Warning

With PI fit indices remain, but non-normality issues too. Consider using robust fit indices.

Tip

LMS/QML are estimated with distribution-analytic likelihood methods (integration / EM-type routines) and the interaction model is not evaluated using the standard “fit covariance matrix \(𝑆\) with \(Σ(θ)\)” machinery.

Difficulty 5 — Interpretation is conditional (probing)

The simple slope of \(X\) depends on \(Z\):

\[ \frac{\partial Y}{\partial X} = \beta_X + \beta_{XZ} Z \]

So there is no single “main effect” of X.

You must probe:

  • simple slopes (e.g., Z = -1, 0, +1 SD)
  • Johnson–Neyman region
  • surface plots

Difficulty 6 — Centering: interpretation vs estimation

Interpretation: \((\beta_X)\) is the effect of X when \((Z=0)\). Centering makes “0” meaningful (often the mean).

Estimation (PI): centering indicators/products helps separate lower-order and interaction variance and stabilizes the model.

Rule of thumb:

  • PI methods: use double-mean centering (e.g., dblcent)
  • LMS/QML: focus on interpretability (what does Z=0 mean?) and probe slopes

Difficulty 7 — Computation & power

  • interaction effects are typically smaller than main effects
  • larger N needed for stable detection
  • PI: bigger model → more convergence problems
  • LMS/QML: numerical integration / iterative routines → slower, sometimes fragile

Evaluate: always report convergence, warnings, and sensitivity checks.

Solutions?

Option 0 — “Observed proxies” (baseline)

  • totals / sum scores
  • factor-score regression

Pros:

  • simple and familiar

Cons:

  • treats measurement error as absent or “handled” by factor scores
  • interaction often attenuates

Option 1 — Latent interaction via product indicators (PI)

Idea: create product indicators and treat them as indicators of a latent interaction factor.

Common centering tricks:

  • mean-centering indicators
  • double-mean centering (default)
  • residual centering (advanced; changes meaning)

Pros:

  • classic SEM machinery applies (often fit indices available)

Cons:

  • parameter heavy
  • identification + correlated residual decisions

Option 2 — Latent Moderated Structural Equations (LMS)

Idea: estimate \((XZ)\) in the likelihood (no product indicators).

Pros:

  • elegant, avoids product-indicator explosion
  • supports **higher-order** interactions

Cons:

  • classical global fit indices typically not available for interaction model
  • must use baseline fit + LRT / IC

Now: fit the latent interaction with modsem

We fit the same SEM twice:

  • PI dblcent (method = "dblcent")
  • LMS (method = "lms") and mention QML

NOTE: You need the modsem package installed.

Latent interaction with PI dblcent

Baseline SEM (no interaction)

library(modsem)

model_base <- '
  intelligence =~ int1 + int2 + int3
  anxiety      =~ anx1 + anx2 + anx3
  achievement  =~ ach1 + ach2 + ach3

  achievement ~ intelligence + anxiety
'

fit_base <- modsem(model_base, data = dat)
# summary(fit_base)

Evaluate baseline fit (global + local) before adding interaction.

PI interaction

model_int <- '
  intelligence =~ int1 + int2 + int3
  anxiety      =~ anx1 + anx2 + anx3
  achievement  =~ ach1 + ach2 + ach3

  achievement ~ intelligence + anxiety +
                intelligence:anxiety
'

fit_dblcent <- modsem(model_int, 
        data = dat, method = "dblcent")
# summary(fit_dblcent)

Check LRT compared to baseline + convergence, sign/magnitude of interaction path, whether estimates are stable (SEs not exploding)

Latent interaction with LMS (and QML)

fit_lms <- modsem(model_int, data = dat, method = "lms")
fit_qml <- modsem(model_int, data = dat, method = "qml")

# summary(fit_lms)
# summary(fit_qml)

Evaluate (LMS/QML):

  • baseline model: classical fit indices
  • interaction model: compare log-likelihood vs baseline (LRT) and check AIC/BIC

Plots

p <- plot_interaction(x = "intelligence", z = "anxiety", 
                      y = "achievement", vals_z = c(-1, 1), 
                      model = fit_lms)
p

LMS evaluation: baseline fit + LRT

# pseudo-code: exact methods depend on the object class and available methods

fit_lms <- modsem(model_int, data = dat, method = "lms")
fit_base <- estimate_h0(fit_lms, calc.se = FALSE)

compare_fit(est_h1 = fit_lms, est_h0 = fit_base)
$D
[1] 23.78654

$df
[1] 1

$p
[1] 1.076325e-06

$diff.loglik
[1] 11.89327

Important

Teaching point: don’t “evaluate fit” of the interaction model using missing CFI/RMSEA. Use baseline fit (this should be good) + LRT logic.

Summary

Regression paths to achievement across models (Estimate (SE)).
model intelligence anxiety intelligence:anxiety
Observed sum-scores (lavaan) 0.383 (0.043) -0.219 (0.040) -0.267 (0.053)
Baseline (no interaction) 0.505 (NA) -0.292 (NA) NA
PI dblcent 0.481 (0.065) -0.247 (0.054) -0.488 (0.119)
LMS 0.478 (0.066) -0.264 (0.054) -0.381 (0.082)
QML 0.488 (0.067) -0.262 (0.054) -0.433 (0.087)

Who’s best? A simple simulation

# simulation parameters
R <- 500; N <- 400; rho_xz <- 0.30
beta_x  <- 0.30; beta_z  <- 0.30 
beta_xz <- 0.20  # <-- ground 
lambda_int <- c(.80, .70, .75); 
lambda_anx <- c(.80, .70, .75)
lambda_ach <- c(.80, .70, .75)
Interaction estimate across methods
model mean_est sd_est bias rmse mean_se power_p
Latent LMS 0.20 0.06 0.00 0.06 0.06 0.91
Latent PI dblcent 0.21 0.07 0.01 0.07 0.06 0.89
Latent QML 0.20 0.06 0.00 0.06 0.06 0.91
Observed sum-scores (lavaan) 0.15 0.05 -0.05 0.07 0.04 0.91
Observed sum-scores (lm) 0.15 0.04 -0.05 0.07 0.04 0.91

Probing the interaction (simple slopes, JN, surface)

# Probing works directly on modsem fits

# simple_slopes(model = fit_lms, x = "intelligence", z = "anxiety", y = "achievement")
# plot_surface(model = fit_lms, x = "intelligence", z = "anxiety", y = "achievement")

plot_jn(model = fit_lms, x = "intelligence", z = "anxiety", y = "achievement")

“Under the hood”: product indicators with semTools + lavaan

library(semTools)

# matched-pair products, double-mean centered
# (x1*z1, x2*z2, x3*z3)
dat_pi <- indProd(
  data = dat,
  var1 = c("int1","int2","int3"),
  var2 = c("anx1","anx2","anx3"),
  match = TRUE,
  meanC = TRUE,
  doubleMC = TRUE
)

prod_names <- grep("^int[1-3].*anx[1-3]$", names(dat_pi), value = TRUE)

model_pi <- paste0('
  intelligence =~ int1 + int2 + int3
  anxiety      =~ anx1 + anx2 + anx3
  achievement  =~ ach1 + ach2 + ach3

  intxn =~ ', paste(prod_names, collapse = " + "), '

  achievement ~ intelligence + anxiety + intxn
')

fit_pi <- lavaan::sem(model_pi, data = dat_pi, meanstructure = TRUE)
summary(fit_pi, fit.measures = TRUE, standardized = TRUE)

Reporting checklist (minimal)

  1. Measurement (baseline): CFA/SEM fit (CFI/TLI/RMSEA/SRMR) + key loadings

  2. Interaction method: PI (dblcent) or LMS/QML (and why)

  3. Evaluation:

  • PI: report SEM fit indices (if available) + local diagnostics
  • LMS/QML: baseline fit indices + LRT (ΔlogLik) or AIC/BIC
  1. Effect reporting: \((\hat\beta_{XZ})\), SE/CI, probing results (simple slopes / JN)

  2. Transparency: centering decisions and what “0” means for the moderator

Interactions as MG-CFA

Group comparisons (classic mg-cfa)

When the interaction term is not a continuous [latent] variable, we can run interactions using MG-CFA (slides here)

m.med=sem(model,data,group="sex",
          group.equal=c("loadings","intercepts",
                        "regressions" # <---------- #
                        )
          )

Group comparisons (handmade mg-cfa)

model_equal <- '
# measurement
intelligence =~ int1 + int2 + int3
achievement  =~ ach1 + ach2 + ach3

# structural path
achievement ~ c(b,b)*intelligence
'
fit_equal <- sem(model_equal, data = dat, 
                 group = "sex")
model_free <- '
# measurement
intelligence =~ int1 + int2 + int3
achievement  =~ ach1 + ach2 + ach3

# structural path
achievement ~ c(b_m, b_f)*intelligence
'
fit_free <- sem(model_free, data = dat, 
                group = "sex")
anova(fit_equal, fit_free)

Chi-Squared Difference Test

          Df    AIC    BIC  Chisq Chisq diff RMSEA Df diff Pr(>Chisq)
fit_free  16 7540.7 7700.9 27.024                                    
fit_equal 17 7539.6 7695.5 27.875    0.85081     0       1     0.3563

Take-home: 3 things

  1. Latent interactions are hard because the product term introduces nonlinearity + non-normality.
  2. PI dblcent is transparent (and often provides classic fit machinery) but can be heavy/fragile.
  3. LMS/QML are elegant for estimation, but they force a new evaluation mindset: baseline fit + LRT, plus probing for interpretation.

References

Jorgensen, T. D., Pornprasertmanit, S., Schoemann, A. M., & Rosseel, Y. (2022). semTools: Useful tools for structural equation modeling. https://CRAN.R-project.org/package=semTools
Rosseel, Y. (2012). lavaan: an R package for structural equation modeling and more Version 0.5-12 (BETA). 37.
Slupphaug, K. S., Mehmetoglu, M., & Mittner, M. (2025). Modsem: An r package for estimating latent interactions and quadratic effects. Structural Equation Modeling: A Multidisciplinary Journal, 32(4), 717–729. https://doi.org/10.1080/10705511.2024.2417409
Solem Slupphaug, K. (n.d.). modsem: Latent Interaction (and Moderation) Analysis in Structural Equation Models (SEM). https://doi.org/10.32614/CRAN.package.modsem