Código
# income deciles (approximate, since income is collected in 10 ordered brackets)
decile_breaks <- unique(quantile(income_code, seq(0, 1, .1), type = 1, na.rm = TRUE))

db$income_10 <- cut(income_code, breaks = decile_breaks, include.lowest = TRUE,
                    labels = paste0("D", seq_len(length(decile_breaks) - 1))) |>
  sjlabelled::set_label("Income decile")

# dichotomized: 0 = deciles 1-9, 1 = decile 10
db$income_top10 <- if_else(as.integer(db$income_10) == length(decile_breaks) - 1, 1, 0) |>
  sjlabelled::set_label("Top income decile")

# dichotomized: 0 = brackets 1-8, 1 = brackets 9-10 (more than $2.700.000)
db$income_high <- if_else(income_code >= 9, 1, 0) |>
  sjlabelled::set_label("High income (brackets 9-10)")

table(db$income_10, useNA = "ifany")

 D1  D2  D3  D4  D5  D6  D7  D8 
514 324 528 332 327 513 660 272 
Código
table(db$income_top10, useNA = "ifany")

   0    1 
3198  272 
Código
table(db$income_high, useNA = "ifany")

   0    1 
3198  272 

4.1 Analysis

4.1.1 Descriptives

Código
item_lab <- "Market justice\nin education"

# proportions by response category
db_likert1 <- db %>%
  filter(!is.na(just_educ)) %>%
  count(cat = just_educ) %>%
  mutate(p = n / sum(n),
         item = item_lab,
         # disagreement goes left (negative), agreement goes right (positive)
         s = if_else(as.integer(cat) <= 2, -p, p))

# stacking order: categories ordered outward from the zero line
db_likert1 <- db_likert1 %>%
  mutate(cat = factor(cat, levels = c("Strongly disagree", "Disagree",
                                      "Agree", "Strongly agree")))

# percentage inside each segment
lab_seg <- db_likert1 %>%
  mutate(side = sign(s)) %>%
  group_by(side) %>%
  arrange(abs(as.integer(cat) - 2.5), .by_group = TRUE) %>%  # category next to zero first
  mutate(x1 = side * (cumsum(p) - p),
         x2 = side * cumsum(p),
         x  = (x1 + x2) / 2,
         lab = scales::percent(p, accuracy = 1)) %>%
  ungroup()

cols <- c("Strongly disagree" = "#6a1718",
          "Disagree"          = "#d08c8c",
          "Agree"             = "#8fb3cf",
          "Strongly agree"    = "#1f4e79")

ggplot(lab_seg, aes(y = item, fill = cat)) +
  geom_tile(aes(x = x, width = p), height = .5) +
  geom_vline(xintercept = 0, linewidth = .35) +
  geom_text(data = lab_seg, aes(x = x, y = item, label = lab),
            inherit.aes = FALSE, size = 3, colour = "white") +
  scale_fill_manual(values = cols, breaks = names(cols)) +
  scale_x_continuous(labels = \(z) scales::percent(abs(z)), limits = c(-1.1, 1.1)) +
  coord_cartesian(clip = "off") +
  labs(x = NULL, y = NULL, fill = NULL) +
  theme_minimal() +
  theme(legend.position = "bottom", panel.grid.major.y = element_blank(),
        plot.margin = margin(5, 4, 5, 10))
Figura 4.1: Market justice in education (diverging Likert plot)

Figura 4.1 shows the distribution of responses on market justice in education. Rejection clearly predominates: 78% of respondents disagree with the statement (38% strongly disagree and 40% disagree), whereas only 22% agree (16% agree and 6% strongly agree). Responses are evenly split between the two disagreement categories, while strong agreement is marginal. Although this suggests that the idea that access to better education should depend on the ability to pay has little legitimacy, we must not overlook the fact that more than one-fifth of the sample supports the commodification of education, which could have significant consequences for the legitimization of educational inequality.

Código
db_corr <- db %>%
  dplyr::select(all_of(vars_m), just_educ) %>%
  mutate(across(everything(), as.numeric)) %>%
  na.omit()

# ordinal items -> polychoric correlations (with p-values, via lavaan)
fit_corr <- lavaan::lavCor(db_corr, ordered = names(db_corr),
                           estimator = "DWLS", se = "standard", output = "fit")
corr_mat <- lavaan::lavInspect(fit_corr, "cor.ov")

pe <- lavaan::parameterEstimates(fit_corr) %>%
  dplyr::filter(op == "~~", lhs != rhs)
p_mat <- matrix(0, nrow(corr_mat), ncol(corr_mat), dimnames = dimnames(corr_mat))
for (k in seq_len(nrow(pe))) {
  p_mat[pe$lhs[k], pe$rhs[k]] <- p_mat[pe$rhs[k], pe$lhs[k]] <- pe$pvalue[k]
}

corr_labels <- c(perc_effort       = "Perc. effort",
                 perc_talent       = "Perc. talent",
                 perc_rich_parents = "Perc. rich parents",
                 perc_contact      = "Perc. contacts",
                 pref_effort       = "Pref. effort",
                 pref_talent       = "Pref. talent",
                 pref_rich_parents = "Pref. rich parents",
                 pref_contact      = "Pref. contacts",
                 just_educ         = "Market justice\neducation")
dimnames(corr_mat) <- list(corr_labels[rownames(corr_mat)],
                           corr_labels[colnames(corr_mat)])

stars <- function(p) {
  dplyr::case_when(p < .001 ~ "***", p < .01 ~ "**", p < .05 ~ "*", TRUE ~ "")
}

corrplot::corrplot(corr_mat, method = "color", type = "lower",
                   tl.col = "black", tl.cex = .8, tl.srt = 45,
                   col = colorRampPalette(c("#6a1718", "white", "#1f4e79"))(200),
                   cl.cex = .7, diag = FALSE)

# coefficient + significance stars in each lower-triangle cell
n_v <- nrow(corr_mat)
for (i in 2:n_v) {
  for (j in 1:(i - 1)) {
    text(j, n_v - i + 1,
         paste0(formatC(corr_mat[i, j], format = "f", digits = 2), stars(p_mat[i, j])),
         cex = .7, font = 2, xpd = NA)
  }
}
Figura 4.2: Polychoric correlations between meritocracy indicators and market justice in education (* p < .05, ** p < .01, *** p < .001)

Figura 4.2 shows the polychoric correlations between the meritocracy indicators and market justice in education. Regarding market justice in education, the strongest associations are with the preference for parental wealth (r = .33) and for contacts (r = .33), indicating that those who consider these privilege factors legitimate are more likely to justify a market-based education. The perception that parental wealth (r = −.19) and contacts (r = −.21) matter in Chile shows a weaker association with market justice in education in the opposite direction. The correlations with merit indicators are small (|r| ≤ .17). Among the indicators, the strongest correlations are those within each dimension: effort and talent (perceived r = .70; preferred r = .47), and parental wealth and contacts (perceived r = .74; preferred r = .72), which supports the distinction between merit and privilege dimensions. Across the results, the only moderate correlations are between perceived non-merit factors and preferred effort (.38 for parental wealth and .43 for contacts).

4.1.2 SEM and market justice education

Código
db_sem2 <- db %>% 
  dplyr::select(just_educ, all_of(vars_m), age, sex, educ, income_5, pol) |> 
  na.omit()

db_sem2$just_educ <- as.numeric(db_sem2$just_educ)

db_sem2 <- db_sem2 %>% 
  mutate(
    across(
      .cols = all_of(vars_m),
      .fns = ~as.numeric(.)
    ))

# asegurar referencias
db_sem2$income_5 <- relevel(db_sem2$income_5, ref = "Q1")
db_sem2$pol <- relevel(db_sem2$pol, ref = "Left")

# crear dummies
X_income <- model.matrix(~ income_5, data = db_sem2)[, -1, drop = FALSE]
X_pol    <- model.matrix(~ pol, data = db_sem2)[, -1, drop = FALSE]

# unir
db_sem2 <- cbind(db_sem2, as.data.frame(X_income), as.data.frame(X_pol))

# limpiar nombres
names(db_sem2) <- make.names(names(db_sem2))

db_sem2$sex_female <- ifelse(db_sem2$sex == "Female", 1, 0)

model <- c('
  perc_merit =~ perc_effort + perc_talent
  perc_nmerit =~ perc_rich_parents + perc_contact
  pref_merit =~ pref_effort + pref_talent
  pref_nmerit =~ pref_rich_parents + pref_contact

  just_educ ~ perc_merit + perc_nmerit + pref_merit + pref_nmerit +
                 age + educ + sex_female +
                 income_5Q2 + income_5Q3 + income_5Q4 + income_5Q5 +
                 polCenter + polRight + polDoes.not.identify
')

ord_vars <- c(
  "just_educ",
  "perc_effort", "perc_talent",
  "perc_rich_parents", "perc_contact",
  "pref_effort", "pref_talent",
  "pref_rich_parents", "pref_contact"
)

fit_sem2 <- lavaan::sem(
  model,
  data = db_sem2,
  estimator = "WLSMV",
  ordered = ord_vars
)

#summary(fit_sem2, fit.measures = TRUE, standardized = TRUE, rsquare = TRUE)

#fitmeasures(fit_sem2, c("chisq", "pvalue", "df", "cfi", "tli", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper", "srmr"))
Figura 4.3: SEM: perceptions and preferences of merit and non-merit factors predicting market justice in education (standardized estimates, WLSMV; controls omitted)

Figura 4.3 presents the standardized estimates of the structural equation model, which includes age, education, sex, income quintiles and political orientation as control variables (omitted from the figure for clarity). The model fits the data well (scaled CFI = .959, TLI = .985, RMSEA = .046, 90% CI [.043, .049], SRMR = .037). Also, the measurement model is satisfactory: all standardized loadings are significant and range from .55 to .92, with the privilege factors being particularly well defined (.81 to .90). Regarding the structural paths, preferences for privilege factor show the strongest association with market justice in education (\(\beta\) = .32, p < .001), indicating that individuals who favor non-meritocratic criteria are more likely to justify a market-based education system. In contrast, perceptions of privilege factors are negatively associated with market justice (\(\beta\) = −.20, p < .001): those who perceive that parental wealth and contacts shape opportunities in Chile are less likely to endorse a market-based education. Perceived merit has a very small positive effect (\(\beta\) = .05, p < .05), while preferred merit is not significantly related to the outcome (\(\beta\) = .04, p = .294). Overall, market justice in education is shaped mainly by beliefs about privilege criteria rather than by meritocratic ones, and perceptions and preferences operate in opposite directions in meritocratic dimension.

4.1.3 Latent class analysis

We estimated latent class models with one to five classes. Model fit improved monotonically as the number of classes increased, as indicated by decreasing AIC and BIC values and statistically significant likelihood-ratio tests at each step (Tabla 4.1).

Código
# ---- build table (no list-cols) -----------------------------------------

N_used <- nrow(db_lca)

m_patterns <- n_patterns_from_data(db_lca)  # scalar

# Robust extractors (avoid list-columns)
get_K <- function(fit) as.integer(ncol(fit$posterior))  # classes = cols of posterior
get_ll  <- function(fit) as.numeric(fit$llik)
get_aic <- function(fit) as.numeric(fit$aic)
get_bic <- function(fit) as.numeric(fit$bic)
get_npar <- function(fit) {
  if (!is.null(fit$npar)) return(as.numeric(fit$npar))
  # fallback: compute from probs
  K <- get_K(fit)
  Rj <- vapply(fit$probs, function(m) ncol(m), integer(1))
  as.numeric(sum(K * (Rj - 1)) + (K - 1))
}

# Table of fit indices
fit_tbl <- tibble(
  K     = map_int(fits, get_K),
  N     = nrow(db_lca),
  logLik= map_dbl(fits, get_ll),
  npar  = map_dbl(fits, get_npar),
  AIC   = map_dbl(fits, get_aic),
  BIC   = map_dbl(fits, get_bic)
) %>%
  arrange(K) %>%
  mutate(
    # Differences relative to preceding (K-1) model
    dAIC = AIC - lag(AIC),
    dBIC = BIC - lag(BIC),
    
    # Likelihood-ratio test (K vs K-1)
    # Test statistic: 2*(LL_K - LL_{K-1}) ~ Chi-square with df = npar_K - npar_{K-1}
    # Note: in LCA, models are not strictly nested in the regular sense;
    # this LRT is widely used as a heuristic, but interpret with caution.
    LRT = 2 * (logLik - lag(logLik)),
    df_LRT = npar - lag(npar),
    p_LRT = ifelse(!is.na(LRT) & df_LRT > 0, pchisq(LRT, df = df_LRT, lower.tail = FALSE), NA_real_)
  )


fit_tbl_report <- fit_tbl %>%
  transmute(
    K, N,
    logLik = round(logLik, 1),
    npar = npar,
    AIC = round(AIC, 1),
    dAIC = round(dAIC, 1),
    BIC = round(BIC, 1),
    dBIC = round(dBIC, 1),
    LRT = round(LRT, 1),
    df_LRT = df_LRT,
    p_LRT = signif(p_LRT, 3)
  )

fit_tbl_report %>% 
 kableExtra::kable(format = "html",
                    align = "c",
                    booktabs = T,
                    escape = F,
                    caption = NULL) %>%
  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")
Tabla 4.1: Fit comparision of latent class models
K N logLik npar AIC dAIC BIC dBIC LRT df_LRT p_LRT
1 2631 -10755.0 8 21525.9 NA 21572.9 NA NA NA NA
2 2631 -10282.4 17 20598.8 -927.1 20698.7 -874.2 945.1 9 0
3 2631 -9960.0 26 19971.9 -626.9 20124.7 -574.0 644.9 9 0
4 2631 -9745.0 35 19560.0 -411.9 19765.6 -359.1 429.9 9 0
5 2631 -9666.0 44 19419.9 -140.0 19678.4 -87.1 158.0 9 0
Código
# Visual: AIC/BIC by K (lower is better)
fit_long <- fit_tbl %>%
  dplyr::select(K, AIC, BIC) %>%
  pivot_longer(-K, names_to = "criterion", values_to = "value")

ggplot(fit_long, aes(x = K, y = value, group = criterion)) +
  geom_line() +
  geom_point() +
  facet_wrap(~criterion, scales = "free_y") +
  labs(x = "Number of classes (K)", y = "Value") 
Figura 4.4: Visual: AIC/BIC by K (lower is better)
Código
# Visual: ΔAIC and ΔBIC (negative = improvement over K-1)
delta_long <- fit_tbl %>%
  dplyr::select(K, dAIC, dBIC) %>%
  pivot_longer(-K, names_to = "delta", values_to = "value")

ggplot(delta_long, aes(x = K, y = value, group = delta)) +
  geom_hline(yintercept = 0) +
  geom_line() +
  geom_point() +
  facet_wrap(~delta, scales = "free_y") +
  labs(x = "K", y = "Change vs K-1 (negative = better)") 
Figura 4.5: ΔAIC and ΔBIC (negative = improvement over K-1)

We can see in Tabla 4.1 that the incremental improvement in fit became substantially smaller after the four-class solution. Whereas the transition from one to four classes yielded large reductions in AIC and BIC, the five-class model provided only a modest additional improvement (AIC = 19,419.9; BIC = 19,678.4) relative to the four-class solution (AIC = 19,560.0; BIC = 19,765.6). Thus, although the five-class model fit the data slightly better in statistical terms, the gain was comparatively limited.

I therefore retained the four-class solution as the final model. This choice was guided by both statistical and substantive criteria: the four-class model achieved a strong balance between goodness of fit, parsimony, and interpretability, whereas the five-class solution did not yield enough additional differentiation to justify the increase in complexity.

4.1.4 Four latent class model

Código
fit4 <- fits[[4]]

# 2) Extract conditional probabilities per item and class
# fit4$probs is a list: each element is K x Rj matrix.
# For dichotomous items: K x 2 (categories = colnames or "1","2")
probs_long <- bind_rows(lapply(names(fit4$probs), function(item) {
  mat <- fit4$probs[[item]]
  df <- as.data.frame(mat)
  df$class <- 1:nrow(mat)
  df$item <- item
  df
})) %>%
  pivot_longer(cols = -c(class, item), names_to = "category", values_to = "prob") %>%
  mutate(
    class = as.integer(class),
    prob = as.numeric(prob)
  )

# If levels are c("0","1") or c("1","2") you'll want the "higher"/"yes" one.
# Here I will assume endorsement is the LAST level (most common after dichotomization).
endorsement_level <- tail(levels(db_lca$a1), 1)

# 4) Build a clean table: P(endorsement | class) for each item
p_endorse <- probs_long %>%
  filter(category == "Pr(2)") %>%
  dplyr::select(item, class, p = prob) %>%
  mutate(item = factor(item, levels = names(fit4$probs)))

# 5) Plot: profile lines (one line per class)
p_endorse <- p_endorse %>% 
  mutate(
    variable = case_when(item == "a1" ~ "perc_effort",
                         item == "a2" ~ "perc_talent",
                         item == "a3" ~ "perc_rich_parents",
                         item == "a4" ~ "perc_contacts",
                         item == "a5" ~ "pref_effort",
                         item == "a6" ~ "pref_talent",
                         item == "a7" ~ "pref_rich_parents",
                         item == "a8" ~ "pref_contacts"
                         ),
    variable = factor(variable,
                      levels = c("perc_effort",
                                 "perc_talent",
                                 "perc_rich_parents",
                                 "perc_contacts",
                                 "pref_effort",
                                 "pref_talent",
                                 "pref_rich_parents",
                                 "pref_contacts")))

p_endorse <- p_endorse %>% 
  mutate(class = factor(class),
         p = round(p,2))

p_endorse_reordered <- p_endorse %>%
  mutate(class = as.character(class)) %>%
  mutate(class = recode(class,
                        "4" = "1",
                        "1" = "4",
                        "2" = "3",
                        "3" = "2")) %>%
  mutate(class = factor(class, levels = c("1","2","3","4")))

g_class <- ggplot(p_endorse_reordered, aes(x = variable, y = p, group = class, colour = class)) +
  geom_line(linewidth = 0.7) +
  geom_point(size = 2) +  
  MetBrewer::scale_color_met_d("VanGogh2") +
  coord_cartesian(ylim = c(0, 1)) +
  labs(x = "Item", y = paste0("P(Y = ", "High", " | class)"), group = "Class", colour = "Class") +
  theme(legend.position = "top",
        text = element_text(size = 14),
        axis.text.x = element_text(angle = 70, vjust = 1, hjust = 1))


library(plotly)

ggplotly(g_class)
Figura 4.6: P(endorsement | class) for each item

Class 1: Critical meritocrats (37.1%)

The first class combines low perceived meritocracy with very high perceived non-meritocracy. Respondents in this group do not believe that effort and talent are what society actually rewards, but they strongly believe that wealthy parents and social contacts shape success. Normatively, however, they strongly support meritocratic rewards and sharply reject the legitimacy of non-meritocratic advantages, especially those associated with personal networks.

This class is particularly important substantively because it captures a configuration in which meritocracy is upheld as a moral ideal while privilege is recognized as an empirical reality. In this sense, respondents in this group clearly distinguish between how society works and how it should work. They do not perceive the current system as meritocratic, but they do express strong support for a society in which effort and talent, rather than inherited or relational advantages, determine success. This class illustrates the analytical value of distinguishing perceptions from preferences, since older one-dimensional approaches to meritocratic beliefs would likely obscure this tension between normative commitment to merit and critical awareness of privilege.

Class 2: Privilege advocates (37.2%)

The second class is characterized by low perceived meritocracy and very high perceived non-meritocracy. Respondents in this group do not believe that effort or talent are strongly rewarded, but they do strongly believe that family wealth and social contacts shape life chances. At the same time, they express high support for rewarding effort and talent, yet also show high normative acceptance of advantages linked to wealthy parents and personal networks.

Substantively, this class combines a combines a realistic assessment of a stratified society with a normative justification of privilege. Thus, meritocracy is not presented as an alternative to privilege; rather, the two principles coexist and are viewed as compatible foundations for the functioning of society. This suggests a dual normative orientation in which merit and privilege are not seen as contradictory principles, but as compatible bases of social inequality.

Class 3: Universal rewards (19.0%)

The third class displays very high levels of both perceived meritocracy and perceived non-meritocracy. Respondents in this group strongly believe that effort and talent are rewarded, while also recognizing the importance of wealthy parents and social contacts. Normatively, they express very strong support for meritocratic rewards, but only moderate acceptance of non-meritocratic advantages.

This profile suggests a belief system in which merit and privilege are understood as operating simultaneously. These respondents do not deny the role of structural advantage, yet neither do they reject the view that effort and talent matter. Normatively, they strongly endorse meritocracy, while showing a more ambivalent stance toward privilege. This class can therefore be interpreted as one that sees the system as at least partially meritocratic and broadly considers this arrangement acceptable.

Class 4: Against privilege (6.7%)

The fourth class is the smallest and least clearly structured. It is characterized by a low perception of meritocracy and a low perception of the influence of privilege, specially the rich parents item. The most significant aspect of this group is that it does not perceive privilege as a factor in the distribution of resources in society, nor does it believe that it should be.

4.1.5 Regression model

Código
fit_polr1 <- MASS::polr(just_educ ~ class4,
                        data = db_reg, Hess = TRUE, method = "logistic")
fit_polr2 <- MASS::polr(just_educ ~ sex + educ + income_5 + pol,
                        data = db_reg, Hess = TRUE, method = "logistic")
fit_polr  <- MASS::polr(just_educ ~ class4 + sex + educ + income_5 + pol,
                        data = db_reg, Hess = TRUE, method = "logistic")
# Model 4: class x university education interaction, plus the remaining controls
fit_polr4 <- MASS::polr(just_educ ~ class4 * educ + sex + income_5 + pol,
                        data = db_reg, Hess = TRUE, method = "logistic")
# Model 5: class x sex interaction, plus the remaining controls
fit_polr5 <- MASS::polr(just_educ ~ class4 * sex + educ + income_5 + pol,
                        data = db_reg, Hess = TRUE, method = "logistic")
# Model 6: class x political identification interaction, plus the remaining controls
fit_polr6 <- MASS::polr(just_educ ~ class4 * pol + sex + educ + income_5,
                        data = db_reg, Hess = TRUE, method = "logistic")

ref_rows <- data.frame(
  term = c("Latent class (Ref = Critical meritocrats)", "Sex (Ref = Male)",
           "University education (Ref = No university)",
           "Income quintile (Ref = Q1)", "Political identification (Ref = Left)",
           "AIC", "BIC"),
  m1 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr1), BIC(fit_polr1)))),
  m2 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr2), BIC(fit_polr2)))),
  m3 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr), BIC(fit_polr)))),
  m4 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr4), BIC(fit_polr4)))),
  m5 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr5), BIC(fit_polr5)))),
  m6 = c(rep("", 5), sprintf("%.1f", c(AIC(fit_polr6), BIC(fit_polr6))))
)
names(ref_rows) <- c("term", paste("Model", 1:6))
hdr_pos <- c(1, 8, 17, 26, 35)   # positions of the reference-category rows
attr(ref_rows, "position") <- c(hdr_pos, 60, 61)

modelsummary::modelsummary(
  list("Model 1" = fit_polr1, "Model 2" = fit_polr2, "Model 3" = fit_polr,
       "Model 4" = fit_polr4,
       "Model 5" = fit_polr5, "Model 6" = fit_polr6),
  exponentiate = TRUE, statistic = "conf.int", stars = TRUE,
  coef_omit = "\\|",
  coef_map = c(
    "class4Privilege advocates" = "Privilege advocates",
    "class4Universal rewards"   = "Universal rewards",
    "class4Against privilege"   = "Against privilege",
    "sexFemale" = "Female",
    "class4Privilege advocates:sexFemale" = "Privilege advocates x Female",
    "class4Universal rewards:sexFemale"   = "Universal rewards x Female",
    "class4Against privilege:sexFemale"   = "Against privilege x Female",
    "educ" = "University education",
    "class4Privilege advocates:educ" = "Privilege advocates x University education",
    "class4Universal rewards:educ"   = "Universal rewards x University education",
    "class4Against privilege:educ"   = "Against privilege x University education",
    "income_5Q2" = "Q2", "income_5Q3" = "Q3", "income_5Q4" = "Q4", "income_5Q5" = "Q5",
    "polCenter" = "Center", "polRight" = "Right",
    "polDoes not identify" = "Does not identify",
    "class4Privilege advocates:polCenter" = "Privilege advocates x Center",
    "class4Universal rewards:polCenter"   = "Universal rewards x Center",
    "class4Against privilege:polCenter"   = "Against privilege x Center",
    "class4Privilege advocates:polRight"  = "Privilege advocates x Right",
    "class4Universal rewards:polRight"    = "Universal rewards x Right",
    "class4Against privilege:polRight"    = "Against privilege x Right",
    "class4Privilege advocates:polDoes not identify" = "Privilege advocates x Does not identify",
    "class4Universal rewards:polDoes not identify"   = "Universal rewards x Does not identify",
    "class4Against privilege:polDoes not identify"   = "Against privilege x Does not identify"
  ),
  gof_map = c("nobs"),
  add_rows = ref_rows,
  output = "tinytable"
) |>
  tinytable::style_tt(i = hdr_pos, bold = TRUE, background = "#f2f2f2")
Tabla 4.2: Ordinal logistic regression of market justice in education (odds ratios)
Model 1 Model 2 Model 3 Model 4 Model 5 Model 6
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
Latent class (Ref = Critical meritocrats)
Privilege advocates 2.496*** 2.278*** 2.254*** 2.403*** 2.055***
[2.098, 2.971] [1.910, 2.719] [1.813, 2.806] [1.867, 3.098] [1.457, 2.902]
Universal rewards 2.317*** 2.037*** 2.096*** 2.199*** 1.611*
[1.880, 2.858] [1.646, 2.521] [1.592, 2.761] [1.641, 2.948] [1.042, 2.482]
Against privilege 2.554*** 2.228*** 2.273*** 2.668*** 3.587**
[1.846, 3.532] [1.601, 3.100] [1.523, 3.390] [1.673, 4.252] [1.621, 7.887]
Sex (Ref = Male)
Female 0.662*** 0.679*** 0.679*** 0.744* 0.687***
[0.568, 0.772] [0.581, 0.792] [0.581, 0.792] [0.572, 0.967] [0.588, 0.802]
Privilege advocates x Female 0.906
[0.640, 1.283]
Universal rewards x Female 0.858
[0.560, 1.312]
Against privilege x Female 0.702
[0.364, 1.352]
University education (Ref = No university)
University education 0.842* 0.824* 0.829 0.822* 0.828*
[0.709, 0.998] [0.694, 0.978] [0.625, 1.097] [0.692, 0.976] [0.697, 0.983]
Privilege advocates x University education 1.028
[0.716, 1.478]
Universal rewards x University education 0.936
[0.609, 1.439]
Against privilege x University education 0.939
[0.465, 1.890]
Income quintile (Ref = Q1)
Q2 0.899 0.920 0.920 0.919 0.919
[0.720, 1.122] [0.736, 1.151] [0.736, 1.150] [0.735, 1.149] [0.734, 1.150]
Q3 0.956 0.956 0.954 0.959 0.956
[0.766, 1.193] [0.765, 1.195] [0.763, 1.192] [0.767, 1.198] [0.765, 1.196]
Q4 1.128 1.161 1.162 1.168 1.158
[0.884, 1.439] [0.909, 1.484] [0.909, 1.486] [0.913, 1.493] [0.905, 1.482]
Q5 1.059 1.038 1.044 1.034 1.035
[0.763, 1.469] [0.745, 1.446] [0.748, 1.454] [0.742, 1.440] [0.742, 1.443]
Political identification (Ref = Left)
Center 2.027*** 1.783*** 1.789*** 1.774*** 1.526
[1.495, 2.747] [1.312, 2.422] [1.316, 2.432] [1.305, 2.410] [0.857, 2.686]
Right 2.977*** 2.629*** 2.641*** 2.607*** 2.685***
[2.423, 3.661] [2.134, 3.243] [2.141, 3.261] [2.113, 3.219] [1.877, 3.847]
Does not identify 1.748*** 1.629*** 1.633*** 1.618*** 1.427*
[1.434, 2.132] [1.334, 1.992] [1.337, 1.997] [1.324, 1.980] [1.044, 1.953]
Privilege advocates x Center 1.475
[0.703, 3.114]
Universal rewards x Center 1.136
[0.471, 2.751]
Against privilege x Center 0.630
[0.175, 2.264]
Privilege advocates x Right 0.910
[0.561, 1.478]
Universal rewards x Right 1.499
[0.836, 2.693]
Against privilege x Right 0.395+
[0.147, 1.063]
Privilege advocates x Does not identify 1.302
[0.838, 2.024]
Universal rewards x Does not identify 1.244
[0.714, 2.169]
Against privilege x Does not identify 0.759
[0.297, 1.952]
AIC 5798.0 5777.0 5687.9 5693.7 5692.6 5692.5
BIC 5832.9 5846.8 5775.2 5798.4 5797.3 5832.2
Num.Obs. 2489 2489 2489 2489 2489 2489

Tabla 4.2 reports four ordinal logistic regressions of market justice in education. Model 1 includes only latent class membership, Model 2 only the sociodemographic and political variables (sex, university education, income quintile and political identification), Model 3 both sets of predictors, Model 4 adds the interaction between latent class and university education to the full model, Model 5 the interaction between latent class and sex, and Model 6 the interaction between latent class and political identification. In Model 1, members of the other three classes have significantly higher odds of endorsing market justice in education than the Critical meritocrats (reference): Privilege advocates (OR = 2.50, 95% CI [2.10, 2.97]), Universal rewards (OR = 2.32, [1.88, 2.86]) and Against privilege (OR = 2.55, [1.85, 3.53]). The three coefficients are similar and their confidence intervals overlap, so the main contrast is between the Critical meritocrats and the remaining classes rather than among the latter. Model 2 shows that women (OR = 0.66) and people with university education (OR = 0.84) have lower odds of market justice, while those identifying with the center (OR = 2.03), the right (OR = 2.98) or not identifying with any position (OR = 1.75) have higher odds than those on the left; income quintiles are not significantly associated with the outcome. In the full model (Model 3), the class coefficients decrease moderately (ORs between 2.04 and 2.28) but remain significant, and the coefficients of the remaining variables change little, with the exception of political identification, whose odds ratios for the center and the right fall from 2.03 to 1.78 and from 2.98 to 2.63. This indicates that the association between latent classes and market justice is not explained by sociodemographic characteristics, although part of it overlaps with political orientation, and that the classes capture belief configurations that are related to, but distinct from, ideological position. Model 3 also has the best fit (AIC = 5,687.9 versus 5,798.0 and 5,777.0 for Models 1 and 2; BIC = 5,775.2 versus 5,832.9 and 5,846.8), so both sets of predictors contribute independently to explaining market justice in education. Finally, Model 4 shows no evidence that the association between latent classes and market justice varies by educational level: none of the interactions between class membership and university education were statistically significant (Privilege advocates OR = 1.03, [0.72, 1.48]; Universal rewards OR = 0.94, [0.61, 1.44]; Against privilege OR = 0.94, [0.47, 1.89]), and the model fits worse than Model 3 (AIC = 5,693.7; BIC = 5,798.4). Models 5 and 6 lead to the same conclusion for sex and political identification. In Model 5 none of the class-by-sex interactions is significant (Privilege advocates OR = 0.91, [0.64, 1.28]; Universal rewards OR = 0.86, [0.56, 1.31]; Against privilege OR = 0.70, [0.36, 1.35]), and in Model 6 none of the nine class-by-political-identification interactions reaches conventional significance either, the closest being Against privilege x Right (OR = 0.39, [0.15, 1.06]). Likelihood-ratio tests against Model 3 confirm that neither set of interactions improves the fit (sex: \(\chi^2\)(3) = 1.34, p = .721; political identification: \(\chi^2\)(9) = 13.37, p = .147), and both models have higher BIC than Model 3 (5,797.3 and 5,832.2 versus 5,775.2). The association between latent classes and market justice in education is therefore similar across sex, educational level and political orientation.

Tabla 4.3: Robustness: modal assignment versus three-step BCH correction (odds ratios, Ref = Critical meritocrats)
Código
# Posterior class probabilities, ordered as the relabelled classes (old 4, 3, 2, 1 -> new 1, 2, 3, 4)
# post <- fit4$posterior[, c(4, 3, 2, 1)]
# rownames(post) <- db_lca$id
# modal_lca <- factor(class_lab[max.col(post)], levels = class_lab)

# # BCH weights: inverse of the classification-error matrix A[s, c] = P(modal = s | class c)
# bch_weights <- function(ids) {
#   p <- post[as.character(ids), , drop = FALSE]
#   m <- factor(class_lab[max.col(p)], levels = class_lab)
#   A <- sapply(1:4, function(cc) tapply(p[, cc], m, sum) / sum(p[, cc]))
#   A[is.na(A)] <- 0
#   list(W = solve(t(A)), modal = as.integer(m))
# }

# # Step 3: stack one copy of the data per class and weight it by the BCH weight
# bch_fit <- function(dat, formula) {
#   bw <- bch_weights(dat$id)
#   stk <- do.call(rbind, lapply(1:4, function(cc) {
#     d <- dat
#     d$cls <- factor(class_lab[cc], levels = class_lab)
#     d$w   <- bw$W[cbind(bw$modal, cc)]
#     d
#   }))
#   suppressWarnings(MASS::polr(formula, data = stk, weights = w,
#                               Hess = FALSE, method = "logistic"))
# }

# f1 <- just_educ ~ cls
# f3 <- just_educ ~ cls + sex + educ + income_5 + pol
# cls_terms <- paste0("cls", class_lab[-1])

# point <- function(dat) {
#   c(exp(coef(bch_fit(dat, f1))[cls_terms]), exp(coef(bch_fit(dat, f3))[cls_terms]))
# }
# est <- point(db_reg)

# # Respondent-level bootstrap of the weights and the outcome model (LCA fixed)
# set.seed(2024)
# B <- 500
# boot <- t(replicate(B, point(db_reg[sample(nrow(db_reg), replace = TRUE), ])))
# ci <- apply(boot, 2, quantile, probs = c(.025, .975))

# fmt <- function(or, lo, hi) sprintf("%.2f [%.2f, %.2f]", or, lo, hi)
# ci_polr <- function(m, nm) {
#   cf <- summary(m)$coefficients[nm, , drop = FALSE]
#   fmt(exp(cf[, 1]), exp(cf[, 1] - 1.96 * cf[, 2]), exp(cf[, 1] + 1.96 * cf[, 2]))
# }
# nm_modal <- paste0("class4", class_lab[-1])

# tab_bch <- data.frame(
#   Class = class_lab[-1],
#   `Model 1 (modal)` = ci_polr(fit_polr1, nm_modal),
#   `Model 1 (BCH)`   = fmt(est[1:3], ci[1, 1:3], ci[2, 1:3]),
#   `Model 3 (modal)` = ci_polr(fit_polr,  nm_modal),
#   `Model 3 (BCH)`   = fmt(est[4:6], ci[1, 4:6], ci[2, 4:6]),
#   check.names = FALSE
# )
# tinytable::tt(tab_bch, notes = "BCH: bootstrap percentile CIs (500 replications); Model 3 includes sex, university education, income quintile and political identification.")

4.2 Latent profile analysis

Código
fit_lpa %>%
  dplyr::select(Classes, LogLik, AIC, BIC, SABIC, Entropy, n_min, BLRT_p) %>%
  mutate(across(c(LogLik, AIC, BIC, SABIC), ~ round(., 0)),
         across(c(Entropy, n_min, BLRT_p), ~ round(., 3))) %>%
  knitr::kable(col.names = c("Profiles", "LogLik", "AIC", "BIC", "SABIC",
                             "Entropy", "Min. class prop.", "BLRT p"))
Tabla 4.4: Latent profile analysis: fit indices by number of profiles
Profiles LogLik AIC BIC SABIC Entropy Min. class prop. BLRT p
1 -24452 48936 49030 48979 1.000 1.000 NA
2 -23873 47796 47943 47863 0.654 0.369 NA
3 -23067 46202 46401 46293 0.722 0.216 NA
4 -22963 46012 46265 46128 0.697 0.107 NA
5 -21503 43110 43415 43250 0.864 0.070 NA
Código
# three-profile solution (selected from the fit indices in tbl-lpa-fit)
tidyLPA::get_estimates(fits_lpa[["model_1_class_3"]]) %>%
  dplyr::filter(Category == "Means") %>%
  mutate(Parameter = factor(Parameter, levels = vars_m),
         Class = factor(Class)) %>%
  ggplot(aes(x = Parameter, y = Estimate, group = Class, colour = Class)) +
  geom_line(linewidth = 0.7) +
  geom_point(size = 2) +
  MetBrewer::scale_color_met_d("VanGogh2") +
  coord_cartesian(ylim = c(1, 4)) +
  labs(x = "Item", y = "Estimated mean (1-4)", colour = "Profile") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "bottom")
Figura 4.7: Estimated means of the meritocracy items by latent profile (three-profile solution)

4.2.1 Size of profiles

Código
size_lpa %>%
  dplyr::transmute(Profile = profile, N = n,
                   `%` = sprintf("%.1f", pct),
                   `Mean posterior probability` = sprintf("%.2f", mean_post)) %>%
  knitr::kable(align = "lrrr")
Tabla 4.5: Size of the latent profiles (modal assignment)
Profile N % Mean posterior probability
Effort prioritizers 567 21.6 0.85
Critical meritocrats 853 32.4 0.85
Privilege advocates 1211 46.0 0.90
Código
ggplot(size_lpa, aes(x = profile, y = pct, fill = profile)) +
  geom_col(width = 0.6, show.legend = FALSE) +
  geom_text(aes(label = sprintf("%.1f%%", pct)), vjust = -0.4, size = 3.5) +
  MetBrewer::scale_fill_met_d("VanGogh2") +
  scale_y_continuous(limits = c(0, max(size_lpa$pct) * 1.15)) +
  labs(x = NULL, y = "Share of respondents (%)") +
  theme_minimal()
Figura 4.8: Size of the latent profiles (share of respondents)

4.2.2 Bivariates of LPA

Código
g_educ   <- plot_biv(db_biv, "educ", "Educational level")
g_income <- plot_biv(db_biv, "income_5", "Income quintile")
g_pol    <- plot_biv(db_biv, "pol", "Political identification")

(g_educ / g_income / g_pol) + patchwork::plot_layout(guides = "collect") &
  theme(legend.position = "bottom")
Figura 4.9: Latent profile composition by educational level, income quintile and political identification (regression sample)

4.2.3 Regression model of LPA

Código
fit_lpa1 <- MASS::polr(just_educ ~ profile,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")
fit_lpa2 <- MASS::polr(just_educ ~ profile + sex + educ + income_5 + pol,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")

# Models 3-5: profile x one political category each; the other two categories
# enter as main effects only (reference: Does not identify)
fit_lpa3 <- MASS::polr(just_educ ~ profile * polLeft + polCenter + polRight + sex + educ + income_5,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")
fit_lpa4 <- MASS::polr(just_educ ~ profile * polCenter + polLeft + polRight + sex + educ + income_5,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")
fit_lpa5 <- MASS::polr(just_educ ~ profile * polRight + polLeft + polCenter + sex + educ + income_5,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")

# Model 6: profile x university education
fit_lpa6 <- MASS::polr(just_educ ~ profile * educ + sex + income_5 + pol,
                       data = db_reg_lpa, Hess = TRUE, method = "logistic")

ref_rows_lpa <- data.frame(
  term = "Latent profile (Ref = Effort prioritizers)",
  m1 = "", m2 = "", m3 = "", m4 = "", m5 = "", m6 = ""
)
names(ref_rows_lpa) <- c("term", paste("Model", 1:6))
attr(ref_rows_lpa, "position") <- 1

modelsummary::modelsummary(
  list("Model 1" = fit_lpa1, "Model 2" = fit_lpa2, "Model 3" = fit_lpa3,
       "Model 4" = fit_lpa4, "Model 5" = fit_lpa5, "Model 6" = fit_lpa6),
  exponentiate = TRUE, statistic = "conf.int", stars = TRUE,
  coef_omit = "\\|",
  coef_map = c(
    "profileCritical meritocrats" = "Critical meritocrats",
    "profilePrivilege advocates" = "Privilege advocates",
    "sexFemale" = "Female",
    "educ" = "University education",
    "income_5Q2" = "Q2", "income_5Q3" = "Q3", "income_5Q4" = "Q4", "income_5Q5" = "Q5",
    "polLeft" = "Left", "polCenter" = "Center", "polRight" = "Right",
    "profileCritical meritocrats:polLeft"   = "Critical meritocrats x Left",
    "profilePrivilege advocates:polLeft"    = "Privilege advocates x Left",
    "profileCritical meritocrats:polCenter" = "Critical meritocrats x Center",
    "profilePrivilege advocates:polCenter"  = "Privilege advocates x Center",
    "profileCritical meritocrats:polRight"  = "Critical meritocrats x Right",
    "profilePrivilege advocates:polRight"   = "Privilege advocates x Right",
    "profileCritical meritocrats:educ" = "Critical meritocrats x University education",
    "profilePrivilege advocates:educ"  = "Privilege advocates x University education"
  ),
  gof_map = c("nobs", "aic", "bic"),
  add_rows = ref_rows_lpa,
  output = "tinytable"
) |>
  tinytable::style_tt(i = 1, bold = TRUE, background = "#f2f2f2")
Tabla 4.6: Ordinal logistic regression of market justice in education on latent profiles (odds ratios)
Model 1 Model 2 Model 3 Model 4 Model 5 Model 6
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
Latent profile (Ref = Effort prioritizers)
Critical meritocrats 0.321*** 0.349*** 0.367*** 0.346*** 0.302*** 0.315***
[0.261, 0.395] [0.283, 0.431] [0.288, 0.467] [0.278, 0.431] [0.236, 0.387] [0.242, 0.410]
Privilege advocates 0.997 1.003 1.016 0.991 0.931 0.948
[0.829, 1.201] [0.832, 1.210] [0.825, 1.252] [0.815, 1.205] [0.741, 1.169] [0.748, 1.202]
Female 0.640*** 0.639*** 0.640*** 0.637*** 0.638***
[0.548, 0.748] [0.547, 0.746] [0.548, 0.747] [0.545, 0.744] [0.546, 0.745]
University education 0.839* 0.843+ 0.839* 0.850+ 0.723+
[0.706, 0.997] [0.709, 1.003] [0.706, 0.997] [0.715, 1.011] [0.522, 1.000]
Q2 0.903 0.906 0.902 0.902 0.905
[0.722, 1.130] [0.724, 1.134] [0.721, 1.129] [0.721, 1.129] [0.723, 1.132]
Q3 0.982 0.981 0.982 0.971 0.983
[0.785, 1.227] [0.785, 1.227] [0.785, 1.227] [0.776, 1.215] [0.786, 1.229]
Q4 1.155 1.158 1.154 1.159 1.159
[0.903, 1.478] [0.906, 1.482] [0.903, 1.477] [0.906, 1.483] [0.906, 1.483]
Q5 1.025 1.026 1.027 1.011 1.024
[0.736, 1.427] [0.736, 1.429] [0.737, 1.430] [0.725, 1.409] [0.735, 1.425]
Left 0.618*** 0.685+ 0.618*** 0.622*** 0.611***
[0.505, 0.756] [0.456, 1.026] [0.505, 0.756] [0.508, 0.761] [0.499, 0.747]
Center 1.072 1.073 0.972 1.063 1.075
[0.798, 1.436] [0.799, 1.438] [0.552, 1.702] [0.791, 1.426] [0.800, 1.440]
Right 1.547*** 1.550*** 1.547*** 1.212 1.555***
[1.279, 1.871] [1.281, 1.875] [1.278, 1.871] [0.863, 1.703] [1.286, 1.882]
Critical meritocrats x Left 0.818
[0.495, 1.351]
Privilege advocates x Left 0.927
[0.577, 1.493]
Critical meritocrats x Center 1.099
[0.493, 2.432]
Privilege advocates x Center 1.159
[0.591, 2.278]
Critical meritocrats x Right 1.711*
[1.067, 2.744]
Privilege advocates x Right 1.259
[0.845, 1.878]
Critical meritocrats x University education 1.319
[0.857, 2.029]
Privilege advocates x University education 1.161
[0.787, 1.712]
Num.Obs. 2489 2489 2489 2489 2489 2489
AIC 5735.8 5626.1 5629.4 5629.9 5625.1 5628.5
BIC 5764.9 5707.5 5722.5 5723.0 5718.2 5721.6