Measuring perceptions and preferences about meritocracy in school in Chile

Longitudinal Invariance analysis

Author

René Canales, Research Helper

Published

June 16, 2025

1 Longitudinal Invariance

2 Libraries

if (!require("pacman")) install.packages("pacman")

pacman::p_load(
  tidyverse,
  sjmisc,
  sjPlot,
  here,
  lavaan,
  psych,
  corrplot,
  ggdist,
  patchwork, #Combina múltiples gráficos de ggplot2 en una sola figura
  semTools,
  gtools,
  kableExtra
)

options(scipen = 999)
rm(list = ls())

3 Data

load(file = here("output", "data", "db_long_proc.RData"))

names(db_long)
glimpse(db_long)
dim(db_long)
db_invariance <-
  db_long %>%
  select(id_estudiante, ola, starts_with(c("perc", "pref"))) %>%
  pivot_wider(
    id_cols = id_estudiante,
    names_from = ola,
    names_glue = "{.value}{ola}",
    values_from = c(
      perc_effort, perc_talent,
      perc_rich_parents, perc_contact,
      pref_effort, pref_talent,
      pref_rich_parents, pref_contact
    )
  ) %>%
  na.omit() %>%
  rename_with(~ str_replace_all(., "_", ""))

names(db_invariance)
names(db_long)

4 Models

4.1 Configural

# Set up model CFA

configural_model_smt <- ('
  percmerit1 =~ perceffort1 + perctalent1
  percmerit2 =~ perceffort2 + perctalent2
  
  percnmerit1 =~ percrichparents1 + perccontact1
  percnmerit2 =~ percrichparents2 + perccontact2
  
  prefmerit1 =~ prefeffort1 + preftalent1
  prefmerit2 =~ prefeffort2 + preftalent2
  
  prefnmerit1 =~ prefrichparents1 + prefcontact1
  prefnmerit2 =~ prefrichparents2 + prefcontact2')


#Configural Model


model_configural <- ('
  # Medición en T1
  percmerit1 =~ perceffort1 + perctalent1
  percnmerit1 =~ percrichparents1 + perccontact1
  prefmerit1 =~ prefeffort1 + preftalent1
  prefnmerit1 =~ prefrichparents1 + prefcontact1

  # Medición en T2
  percmerit2 =~ perceffort2 + perctalent2
  percnmerit2 =~ percrichparents2 + perccontact2
  prefmerit2 =~ prefeffort2 + preftalent2
  prefnmerit2 =~ prefrichparents2 + prefcontact2
')

fit_configural <- cfa(model_configural, data = db_invariance, std.lv = TRUE, meanstructure = TRUE) 

#Usar la opción std.lv = TRUE en modelos de ecuaciones estructurales o análisis factorial confirmatorio implica cambiar la forma en que identificamos los factores latentes. En lugar de fijar una de las cargas factoriales a 1 —lo que equivale a usar ese ítem como “regla” para escalar el factor—, se opta por fijar la varianza de la variable latente en 1 y permitir que todas las cargas se estimen libremente. Esta decisión tiene varias ventajas importantes, especialmente cuando se trabaja con modelos longitudinales o con análisis de invarianza factorial.

#Primero, usar std.lv = TRUE garantiza que todas las cargas sean comparables entre sí, ya que ninguna está restringida arbitrariamente. Esto es relevante en análisis donde queremos examinar si la estructura factorial se mantiene estable en el tiempo o entre grupos, pues evita dar un peso privilegiado a un ítem específico. Segundo, facilita la interpretación de los parámetros, ya que al fijar la varianza de los factores a 1, cada carga expresa directamente la relación estándar (es decir, la contribución en unidades de desviación estándar del ítem al factor), sin depender de la escala arbitraria inducida por un ítem marcador.

#Además, cuando se busca evaluar la invarianza métrica, escalar o estricta, std.lv = TRUE permite aplicar restricciones de igualdad en las cargas e interceptos de forma simétrica y clara. Por contraste, si se usa el método de identificación por marcador (una carga fija), entonces la carga fijada no se puede restringir entre grupos o tiempos, lo que limita la capacidad para probar igualdad completa. Por esta razón, los procedimientos automatizados como measEq.syntax() del paquete semTools usan por defecto este tipo de identificación, justamente para garantizar flexibilidad y simetría en los modelos de invarianza.

#Finalmente, desde una perspectiva estadística, la identificación mediante varianza del factor fija (std.lv) es más coherente cuando los factores representan construcciones latentes homogéneas. A diferencia de fijar una carga (lo cual implica que ese ítem define el factor), fijar la varianza del factor implica que el constructo se mide en una escala fija y abstracta, lo que es más neutral teóricamente. Esto es especialmente importante si el ítem marcador cambia de comportamiento o fiabilidad entre grupos o tiempos: al no depender de un ítem particular, evitamos distorsiones en la escala del factor.

#En resumen, std.lv = TRUE ofrece ventajas tanto prácticas como teóricas: evita sesgos en la identificación, permite comparaciones limpias entre grupos o momentos, y proporciona estimaciones más estables y fáciles de interpretar en modelos complejos, como los de invarianza longitudinal.

summary(fit_configural, fit.measures = TRUE, standardized = TRUE)

#To see the internal representation of the model (this helps understand the model definition)
inspect(fit_configural,what="list")

4.2 Metric (Weak)

# Equal Loadings

model_metric <- ('
  percmerit1 =~ v1*perceffort1 + v2*perctalent1
  percmerit2 =~ v1*perceffort2 + v2*perctalent2

  percnmerit1 =~ v3*percrichparents1 + v4*perccontact1
  percnmerit2 =~ v3*percrichparents2 + v4*perccontact2

  prefmerit1 =~ v5*prefeffort1 + v6*preftalent1
  prefmerit2 =~ v5*prefeffort2 + v6*preftalent2

  prefnmerit1 =~ v7*prefrichparents1 + v8*prefcontact1
  prefnmerit2 =~ v7*prefrichparents2 + v8*prefcontact2
')

fit_metric <- cfa(model_metric, data = db_invariance,  std.lv = TRUE, meanstructure = TRUE)
summary(fit_metric, fit.measures = TRUE, standardized = TRUE)

4.3 Scalar (Strong)

# Equal Loadings and intercepts

model_scalar <- ('
  # Igualdad de cargas
  percmerit1 =~ v1*perceffort1 + v2*perctalent1
  percmerit2 =~ v1*perceffort2 + v2*perctalent2

  percnmerit1 =~ v3*percrichparents1 + v4*perccontact1
  percnmerit2 =~ v3*percrichparents2 + v4*perccontact2

  prefmerit1 =~ v5*prefeffort1 + v6*preftalent1
  prefmerit2 =~ v5*prefeffort2 + v6*preftalent2

  prefnmerit1 =~ v7*prefrichparents1 + v8*prefcontact1
  prefnmerit2 =~ v7*prefrichparents2 + v8*prefcontact2

  # Igualdad de interceptos
  perceffort1 ~ int1*1
  perceffort2 ~ int1*1

  perctalent1 ~ int2*1
  perctalent2 ~ int2*1

  percrichparents1 ~ int3*1
  percrichparents2 ~ int3*1

  perccontact1 ~ int4*1
  perccontact2 ~ int4*1

  prefeffort1 ~ int5*1
  prefeffort2 ~ int5*1

  preftalent1 ~ int6*1
  preftalent2 ~ int6*1

  prefrichparents1 ~ int7*1
  prefrichparents2 ~ int7*1

  prefcontact1 ~ int8*1
  prefcontact2 ~ int8*1
')

fit_scalar <- cfa(model_scalar, data = db_invariance, std.lv = TRUE, meanstructure = TRUE)
summary(fit_scalar, fit.measures = TRUE, standardized = TRUE)

4.4 Strict

# Equal Loadings, intercepts and variance

model_strict <- paste0(model_scalar, '
  perceffort1 ~~ e1*perceffort1
  perceffort2 ~~ e1*perceffort2

  perctalent1 ~~ e2*perctalent1
  perctalent2 ~~ e2*perctalent2

  percrichparents1 ~~ e3*percrichparents1
  percrichparents2 ~~ e3*percrichparents2

  perccontact1 ~~ e4*perccontact1
  perccontact2 ~~ e4*perccontact2

  prefeffort1 ~~ e5*prefeffort1
  prefeffort2 ~~ e5*prefeffort2

  preftalent1 ~~ e6*preftalent1
  preftalent2 ~~ e6*preftalent2

  prefrichparents1 ~~ e7*prefrichparents1
  prefrichparents2 ~~ e7*prefrichparents2

  prefcontact1 ~~ e8*prefcontact1
  prefcontact2 ~~ e8*prefcontact2
')

fit_strict <- cfa(model_strict, data = db_invariance, std.lv = TRUE, meanstructure = TRUE)
summary(fit_strict, fit.measures = TRUE, standardized = TRUE)

5 Comparative Table

# Compare ajust
tab01 <- lavTestLRT(fit_configural, fit_metric, fit_scalar, fit_strict) %>%
  dplyr::as_tibble() %>%
  dplyr::select("Chisq", "Df", "chisq_diff" = `Chisq diff`, "df_diff" = `Df diff`, "pvalue" = `Pr(>Chisq)`) %>%
  dplyr::mutate(
    stars = gtools::stars.pval(pvalue),
    chisqt = paste0(round(Chisq, 2), " (", Df, ") "),
    decision = ifelse(pvalue > 0.05, yes = "Accept", no = "Reject"),
    model = c("Configural", "Weak", "Strong", "Strict")
  )

fit.meas <- dplyr::bind_rows(
  lavaan::fitmeasures(fit_configural, output = "matrix")[c("chisq", "df", "cfi", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper"), ],
  lavaan::fitmeasures(fit_metric, output = "matrix")[c("chisq", "df", "cfi", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper"), ],
  lavaan::fitmeasures(fit_scalar, output = "matrix")[c("chisq", "df", "cfi", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper"), ],
  lavaan::fitmeasures(fit_strict, output = "matrix")[c("chisq", "df", "cfi", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper"), ]
)

fit.meas <- fit.meas %>%
  dplyr::mutate(
    diff.chi2 = chisq - lag(chisq, default = dplyr::first(chisq)),
    diff.df = df - lag(df, default = dplyr::first(df)),
    diff.cfi = cfi - lag(cfi, default = dplyr::first(cfi)),
    diff.rmsea = rmsea - lag(rmsea, default = dplyr::first(rmsea))
  ) %>%
  round(3) %>%
  dplyr::mutate(rmsea.ci = paste0(rmsea, " \n ", "(", rmsea.ci.lower, "-", rmsea.ci.upper, ")"))

tab.inv <- dplyr::bind_cols(tab01, fit.meas) %>%
  dplyr::select(model, chisqt, cfi, rmsea.ci, diff.chi2, diff.df, diff.cfi, diff.rmsea, stars, decision) %>%
  dplyr::mutate(diff.chi2 = paste0(diff.chi2, " (", diff.df, ") ", stars)) %>%
  dplyr::select(model, chisqt, cfi, rmsea.ci, diff.chi2, diff.cfi, diff.rmsea, decision)

# clean values
tab.inv[tab.inv == c("0 (0) ")] <- NA
tab.inv[tab.inv == c(0)] <- NA

col.nam <- c(
  "Model", "&chi;^2 (df)", "CFI", "RMSEA (90 CI)",
  "&Delta; &chi;^2 (&Delta; df)", "&Delta; CFI", "&Delta; RMSEA", "Decision"
)
footnote <- paste0("N = ", fit_configural@Data@nobs[[1]])

tab.inv %>%
  kableExtra::kable(
    format = "html",
    align = "c",
    booktabs = T,
    escape = F,
    caption = NULL,
    col.names = col.nam
  ) %>%
  kableExtra::kable_styling(
    full_width = T,
    latex_options = "hold_position",
    bootstrap_options = c("striped", "bordered", "condensed"),
    font_size = 23
  ) %>%
  kableExtra::column_spec(c(1, 8), width = "3.5cm") %>%
  kableExtra::column_spec(2:7, width = "4cm") %>%
  kableExtra::column_spec(4, width = "5cm")
Compare Models
Model χ^2 (df) CFI RMSEA (90 CI) Δ χ^2 (Δ df) Δ CFI Δ RMSEA Decision
Configural 207.84 (76) 0.930 0.054 (0.045-0.063)
Weak 220.88 (84) 0.927 0.053 (0.044-0.061) 13.043 (8) -0.003 -0.002 Accept
Strong 234.36 (92) 0.924 0.051 (0.043-0.059) 13.479 (8) . -0.003 -0.001 Accept
Strict 245.88 (100) 0.922 0.05 (0.042-0.058) 11.52 (8) -0.002 -0.001 Accept