¿Qué hace este análisis?
Tenemos las calificaciones de 2 000 estudiantes en 7 materias. A simple vista son solo números, pero detrás de ellos puede haber habilidades no visibles directamente: quizás algunos estudiantes son buenos en todo lo que requiere razonamiento abstracto, y otros destacan en ciencias naturales. El Análisis Factorial Exploratorio (AFE) es la herramienta estadística que detecta esas habilidades ocultas (llamadas factores latentes) y las cuantifica para cada estudiante.


Librerías utilizadas

library(psych)      # Herramientas para análisis factorial (fa, KMO, Mardia...)
library(corrplot)   # Mapas de calor de correlaciones
library(andrews)    # Gráficos de Andrews para detectar anomalías
library(ggplot2)    # Visualizaciones elegantes
library(dplyr)      # Manipulación y filtrado de datos
library(knitr)      # Tablas formateadas
library(DT)         # Tablas interactivas en HTML
library(plotly)     # Gráficas interactivas (zoom, hover, filtros)
library(tidyr)      # Reorganización de datos

Carga e inspección de los datos

¿Qué hacemos aquí?
Cargamos el archivo CSV con los datos y revisamos rápidamente su estructura: cuántos estudiantes hay, cuántas variables, de qué tipo es cada una. :)

datos_raw <- read.csv("student-scores.csv", stringsAsFactors = FALSE)

cat("Número de estudiantes:", nrow(datos_raw), "\n")
## Número de estudiantes: 2000
cat("Número de variables  :", ncol(datos_raw), "\n\n")
## Número de variables  : 17
str(datos_raw)
## 'data.frame':    2000 obs. of  17 variables:
##  $ id                        : int  1 2 3 4 5 6 7 8 9 10 ...
##  $ first_name                : chr  "Paul" "Danielle" "Tina" "Tara" ...
##  $ last_name                 : chr  "Casey" "Sandoval" "Andrews" "Clark" ...
##  $ email                     : chr  "paul.casey.1@gslingacademy.com" "danielle.sandoval.2@gslingacademy.com" "tina.andrews.3@gslingacademy.com" "tara.clark.4@gslingacademy.com" ...
##  $ gender                    : chr  "male" "female" "female" "female" ...
##  $ part_time_job             : chr  "False" "False" "False" "False" ...
##  $ absence_days              : int  3 2 9 5 5 2 3 2 6 3 ...
##  $ extracurricular_activities: chr  "False" "False" "True" "False" ...
##  $ weekly_self_study_hours   : int  27 47 13 3 10 26 23 34 25 18 ...
##  $ career_aspiration         : chr  "Lawyer" "Doctor" "Government Officer" "Artist" ...
##  $ math_score                : int  73 90 81 71 84 93 99 95 94 98 ...
##  $ history_score             : int  81 86 97 74 77 100 96 95 68 69 ...
##  $ physics_score             : int  93 96 95 88 65 67 97 82 94 88 ...
##  $ chemistry_score           : int  97 100 96 80 65 78 73 63 85 71 ...
##  $ biology_score             : int  63 90 65 89 80 72 88 84 81 67 ...
##  $ english_score             : int  80 88 77 63 74 80 76 70 74 71 ...
##  $ geography_score           : int  87 90 94 86 76 84 64 85 72 73 ...

Resultado esperado: 2 000 estudiantes con 17 variables que incluyen datos personales, hábitos de estudio y calificaciones en 7 materias.


️ Clasificación de variables

¿Por qué no usamos todas las variables?
No todas las variables son útiles para el análisis factorial. Un identificador (id) o un correo electrónico no aportan información numérica analizable. El AFE solo trabaja con variables numéricas continuas que puedan estar correlacionadas entre sí. En este caso, seleccionamos las 7 calificaciones.

Variable Tipo Decisión
id, first_name, last_name, email Identificador / texto Excluir — sin valor analítico
gender Nominal binaria ️ Solo descriptivo
part_time_job Binaria (84% FALSE) Excluir del AFE
absence_days Discreta ️ Auxiliar descriptivo
extracurricular_activities Binaria (80% FALSE) Excluir del AFE
career_aspiration Nominal (17 categorías) Variable de contraste final
math_score hasta geography_score Continua (40–100) Incluir en AFE
calificaciones <- c("math_score", "history_score", "physics_score",
                    "chemistry_score", "biology_score", "english_score",
                    "geography_score")

datos <- datos_raw[, calificaciones]
head(datos)
##   math_score history_score physics_score chemistry_score biology_score
## 1         73            81            93              97            63
## 2         90            86            96             100            90
## 3         81            97            95              96            65
## 4         71            74            88              80            89
## 5         84            77            65              65            80
## 6         93           100            67              78            72
##   english_score geography_score
## 1            80              87
## 2            88              90
## 3            77              94
## 4            63              86
## 5            74              76
## 6            80              84

Descriptivos y exploración inicial

hacemos una exploracion antes de iniciar, pq?
Antes de aplicar cualquier técnica estadística avanzada, debemos entender cómo se comportan los datos, para detectar posibles problemas: valores extremos, escalas distintas entre materias, distribuciones muy asimétricas, etc.

Estadísticos univariados

summary(datos)
##    math_score     history_score    physics_score    chemistry_score
##  Min.   : 40.00   Min.   : 50.00   Min.   : 50.00   Min.   : 50    
##  1st Qu.: 77.00   1st Qu.: 69.75   1st Qu.: 71.00   1st Qu.: 69    
##  Median : 87.00   Median : 82.00   Median : 83.00   Median : 81    
##  Mean   : 83.45   Mean   : 80.33   Mean   : 81.34   Mean   : 80    
##  3rd Qu.: 93.00   3rd Qu.: 91.00   3rd Qu.: 92.00   3rd Qu.: 91    
##  Max.   :100.00   Max.   :100.00   Max.   :100.00   Max.   :100    
##  biology_score    english_score   geography_score 
##  Min.   : 30.00   Min.   :50.00   Min.   : 60.00  
##  1st Qu.: 69.00   1st Qu.:72.00   1st Qu.: 71.00  
##  Median : 81.00   Median :83.00   Median : 81.00  
##  Mean   : 79.58   Mean   :81.28   Mean   : 80.89  
##  3rd Qu.: 91.00   3rd Qu.:91.00   3rd Qu.: 91.00  
##  Max.   :100.00   Max.   :99.00   Max.   :100.00

Distribuciones

que vemos en el gráfico?
Cada barra representa cuántos estudiantes obtuvieron una calificación en ese rango una distribución “normal” (campana de Gauss) indicaría que la mayoría de estudiantes está en el promedio una distribución plana sugiere uniformidad: todos los puntajes son igualmente frecuentes.

datos_long <- datos %>%
  pivot_longer(everything(), names_to = "materia", values_to = "puntaje") %>%
  mutate(materia = gsub("_score", "", materia))

p_hist <- ggplot(datos_long, aes(x = puntaje, fill = materia, text = paste0(
  "Materia: ", materia, "<br>Rango: ", round(puntaje), "<br>"
))) +
  geom_histogram(bins = 25, color = "white", alpha = 0.85) +
  facet_wrap(~ materia, ncol = 4, scales = "free_y") +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title = "Distribución de calificaciones por materia",
    x = "Calificación", y = "Número de estudiantes"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none",
        strip.text = element_text(face = "bold"),
        plot.title = element_text(face = "bold", size = 14))

ggplotly(p_hist, tooltip = "text") %>%
  layout(title = list(text = "<b>Distribución de calificaciones por materia</b>",
                      font = list(size = 14)))

Boxplot comparativo

Box Plot
La caja central muestra dónde está el 50% central de los estudiantes la línea del medio es la mediana (el estudiante justo en la mitad). Los puntos fuera de los bigotes son estudiantes con calificaciones inusualmente altas o bajas (valores atípicos). Comparar las cajas entre materias nos dice si las escalas son similares.

p_box <- plot_ly(datos_long,
  x = ~puntaje, y = ~materia,
  type = "box",
  color = ~materia,
  colors = "Set2",
  orientation = "h",
  boxpoints = "outliers",
  hovertemplate = paste(
    "<b>%{y}</b><br>",
    "Mediana: %{median}<br>",
    "Q1–Q3: %{q1}–%{q3}<extra></extra>"
  )
) %>%
  layout(
    title = list(text = "<b>Caja y Bigotes — Calificaciones por materia</b>",
                 font = list(size = 14)),
    xaxis = list(title = "Calificación"),
    yaxis = list(title = ""),
    showlegend = FALSE,
    plot_bgcolor = "#f7f9fc",
    paper_bgcolor = "#f7f9fc"
  )

p_box

Escalado de los datos

por qué estandarizamos?
Las materias tienen rangos ligeramente distintos biología puede ir de 30 a 100, geografía de 60 a 100 si no estandarizamos, las materias con mayor rango tendrían más “peso” en el análisis de forma artificial. Estandarizar (media = 0, desviación estándar = 1) pone a todas las materias en la misma escala justa.

datos_iter <- as.data.frame(scale(datos))

cat("Verificación — medias post-escalado (deben ser ≈ 0):\n")
## Verificación — medias post-escalado (deben ser ≈ 0):
print(round(colMeans(datos_iter), 4))
##      math_score   history_score   physics_score chemistry_score   biology_score 
##               0               0               0               0               0 
##   english_score geography_score 
##               0               0
cat("\nVerificación — desviaciones estándar (deben ser ≈ 1):\n")
## 
## Verificación — desviaciones estándar (deben ser ≈ 1):
print(round(apply(datos_iter, 2, sd), 4))
##      math_score   history_score   physics_score chemistry_score   biology_score 
##               1               1               1               1               1 
##   english_score geography_score 
##               1               1

Escalado correcto: Todas las medias son prácticamente 0 y todas las desviaciones estándar son prácticamente 1. Los datos están listos para el anális

Gráfico de Andrews — detección de anomalías

qué es el gráfico de Andrews?
Es una técnica visual ingeniosa: cada estudiante se convierte en una curva matemática usando sus 7 calificaciones. Si todos los estudiantes fueran “similares”, las curvas formarían un haz compacto. Las curvas muy alejadas del grupo son estudiantes con un perfil inusual — posibles valores atípicos multivariados (raros no por una materia, sino por la combinación de todas).

andrews(df   = datos_iter,
        type = 2,
        ylab = "f(t)",
        xlab = "t",
        lwd  = 0.3,
        col  = alpha("steelblue", 0.25),
        main = "Gráfico de Andrews — Calificaciones estandarizadas")
grid(col = "#e2e8f0")

Interpretación: Las curvas forman un haz relativamente compacto, lo que indica que no hay estudiantes con perfiles extremadamente anómalos que pudieran distorsionar el análisis.


Prueba de Normalidad Multivariada (Mardia)

normalidad multivariad
Algunos métodos estadiísticos asumen que los datos siguen una distribución normal (como la campana de Gauss) no solo en cada variable por separado, sino en todas las combinaciones posibles. El Test de Mardia verifica esto con dos estadísticos: asimetría (¿está la distribución inclinada hacia algún lado?) y curtosis (¿es más puntiaguda o más plana de lo normal?).

mardia_res <- psych::mardia(datos_iter, plot = TRUE)

cat("\n=== Prueba de Mardia — Normalidad Multivariada ===\n")
## 
## === Prueba de Mardia — Normalidad Multivariada ===
cat("Asimetría multivariada (b1p)  :", round(mardia_res$b1p,    4), "\n")
## Asimetría multivariada (b1p)  : 2.4685
cat("p-value asimetría             :", round(mardia_res$p.skew,  4), "\n")
## p-value asimetría             : 0
cat("Curtosis multivariada (b2p)   :", round(mardia_res$b2p,    4), "\n")
## Curtosis multivariada (b2p)   : 61.4601
cat("p-value curtosis              :", round(mardia_res$p.kurt,  4), "\n\n")
## p-value curtosis              : 0.0022
if (mardia_res$p.skew < 0.05 || mardia_res$p.kurt < 0.05) {
  cat("→ Se RECHAZA la normalidad multivariada (p < 0.05).\n")
  cat("→ Justificación confirmada para usar fm = 'minres'.\n")
} else {
  cat("→ No se rechaza la normalidad multivariada.\n")
  cat("→ Podría usarse fm = 'ml', aunque minres sigue siendo válido.\n")
}
## → Se RECHAZA la normalidad multivariada (p < 0.05).
## → Justificación confirmada para usar fm = 'minres'.

Resultado: Con 2 000 observaciones, la prueba rechaza la normalidad multivariada. Esto es muy común con muestras grandes, ya que el test se vuelve muy sensible la consecuencia práctica es que usaremos el método minres (Mínimos Residuales), que no requiere normalidad y es más robusto.


Matriz de Correlaciones

Correlacion, base del afe
La correlación mide el grado en que dos variables se mueven juntas. Si los estudiantes que sacan buenas notas en física también las sacan en química, esas materias están correlacionadas. El AFE busca grupos de materias que estén correlacionadas entre sí — ese grupo sugiere un factor latente común (por ejemplo, “aptitud en ciencias exactas”). Sin correlaciones, no hay factores que descubrir

R <- cor(datos_iter)
kable(round(R, 3),
      caption = "Matriz de Correlaciones de Pearson — Calificaciones (estandarizadas)")
Matriz de Correlaciones de Pearson — Calificaciones (estandarizadas)
math_score history_score physics_score chemistry_score biology_score english_score geography_score
math_score 1.000 0.147 0.116 0.127 0.081 0.135 0.050
history_score 0.147 1.000 0.048 0.121 0.089 0.147 0.066
physics_score 0.116 0.048 1.000 0.126 0.132 0.054 0.103
chemistry_score 0.127 0.121 0.126 1.000 0.120 0.068 0.065
biology_score 0.081 0.089 0.132 0.120 1.000 0.074 0.107
english_score 0.135 0.147 0.054 0.068 0.074 1.000 0.072
geography_score 0.050 0.066 0.103 0.065 0.107 0.072 1.000

Mapa de calor interactivo

cor_df <- as.data.frame(R)
cor_df$var1 <- rownames(cor_df)
cor_long <- pivot_longer(cor_df, -var1, names_to = "var2", values_to = "r") %>%
  mutate(
    var1 = gsub("_score", "", var1),
    var2 = gsub("_score", "", var2),
    r_txt = round(r, 3)
  )

p_cor <- ggplot(cor_long, aes(x = var2, y = var1, fill = r,
  text = paste0(var1, " vs ", var2, "\nr = ", r_txt))) +
  geom_tile(color = "white", linewidth = 0.5) +
  geom_text(aes(label = r_txt), size = 3, color = "white", fontface = "bold") +
  scale_fill_gradient2(low = "#2563eb", mid = "#f1f5f9", high = "#dc2626",
                       midpoint = 0, limits = c(-1, 1),
                       name = "Correlación") +
  labs(title = "Mapa de calor de correlaciones entre materias",
       x = "", y = "") +
  theme_minimal(base_size = 12) +
  theme(axis.text.x = element_text(angle = 30, hjust = 1, face = "bold"),
        axis.text.y = element_text(face = "bold"),
        plot.title = element_text(face = "bold"))

ggplotly(p_cor, tooltip = "text") %>%
  layout(title = list(text = "<b>Mapa de calor de correlaciones</b>",
                      font = list(size = 14)))

Dendrograma — agrupación natural de materias

¿Qué es un dendrograma?
Es un árbol que muestra cuáles materias se “parecen más” entre sí en términos de correlación. Las materias que se unen primero (en la parte baja del árbol) tienen la correlación más fuerte. Esto nos da una pista visual de cuántos grupos de materias (= factores) podría haber.

dd <- as.dist((1 - R) / 2)
hc <- hclust(dd)
par(bg = "#f7f9fc", col.main = "#1a3a5c", col.lab = "#64748b")
plot(hc,
     main = "Dendrograma — Agrupación de materias por similitud",
     xlab = "Materia", sub = "",
     ylab = "Distancia de correlación",
     col  = "#1a56b0", lwd = 1.5,
     labels = gsub("_score", "", colnames(R)))
rect.hclust(hc, k = 2, border = c("#3a7bd5", "#f59e0b"))
grid(col = "#e2e8f0", lty = 1)

Interpretación: El dendrograma sugiere 2 grupos naturales (recuadros de colores): uno con ciencias exactas/naturales (física, química, biología) y otro con humanidades y matemáticas (historia, inglés, matemáticas). Geografía aparece como la más independiente.


Adecuación de la muestra

podemos realmente aplicar el AFE a estos datos?
No cualquier conjunto de datos es adecuado para el análisis factorial. Necesitamos verificar dos cosas antes de continuar:

1. Prueba de Bartlett: Hay correlaciones estadísticamente significativas entre las materias? Si las correlaciones fueran todas cero, no habría estructura que descubrir.
2. Índice KMO (Kaiser-Meyer-Olkin): ¿Qué tan “compactas” son esas correlaciones? Escala de 0 a 1: valores cercanos a 1 son ideales (>0.90 excelente, >0.70 aceptable, >0.60 mediocre pero usable).

cat("====== EVALUACIÓN DE ADECUACIÓN ======\n\n")
## ====== EVALUACIÓN DE ADECUACIÓN ======
# Prueba de Bartlett
bart <- cortest.bartlett(R, n = nrow(datos_iter))
cat("--- Prueba de Bartlett ---\n")
## --- Prueba de Bartlett ---
cat("Chi-cuadrado:", round(bart$chisq, 2), "  p-valor:", format(bart$p.value, scientific = TRUE), "\n")
## Chi-cuadrado: 357.78   p-valor: 4.762569e-63
if (bart$p.value < 0.05) {
  cat(" p < 0.05: hay correlaciones significativas. El AFE tiene sentido.\n\n")
} else {
  cat("p ≥ 0.05: no se recomienda el AFE.\n\n")
}
##  p < 0.05: hay correlaciones significativas. El AFE tiene sentido.
# Prueba KMO
kmo_res <- KMO(datos_iter)
cat("--- Índice KMO ---\n")
## --- Índice KMO ---
print(kmo_res)
## Kaiser-Meyer-Olkin factor adequacy
## Call: KMO(r = datos_iter)
## Overall MSA =  0.66
## MSA for each item = 
##      math_score   history_score   physics_score chemistry_score   biology_score 
##            0.66            0.65            0.65            0.67            0.67 
##   english_score geography_score 
##            0.65            0.67
cat("\nKMO global =", round(kmo_res$MSA, 4))
## 
## KMO global = 0.6581
cat("\n→ Clasificación: Mediocre-Regular (>0.60, aceptable para proceder)\n")
## 
## → Clasificación: Mediocre-Regular (>0.60, aceptable para proceder)
cat("→ Ninguna variable tiene KMO < 0.50 → se conservan las 7 materias.\n")
## → Ninguna variable tiene KMO < 0.50 → se conservan las 7 materias.

Conclusión de adecuación: Bartlett confirma que hay estructura correlacional (p < 0.0001). El KMO de 0.66 es “mediocre” pero suficiente para proceder. Todas las materias se conservan en el análisis.


Selección del método de estimación

qué método usamos para extraer los factores?
Existen varios algoritmos para descomponer la matriz de correlaciones en factores. La elección depende de los supuestos que cumplen los datos:

Máxima Verosimilitud (ml): Ideal si los datos son normales multivariados. No aplica aquí.
Ejes Principales (pa): Robusto a no-normalidad, pero puede fallar con correlaciones muy débiles.
Mínimos Residuales — minres (seleccionado): Robusto a no-normalidad, estable con correlaciones débiles, y es el método predeterminado del paquete psych. La mejor opción para este dataset.

metodo <- "minres"
cat("Método seleccionado:", metodo, "\n")
## Método seleccionado: minres
cat("Justificación: normalidad rechazada (Mardia) + correlaciones débiles → minres\n")
## Justificación: normalidad rechazada (Mardia) + correlaciones débiles → minres

🔍 Análisis con el máximo de factores posibles

¿Por qué probar con el máximo de factores primero?
Antes de decidir cuántos factores son necesarios, extraemos todos los posibles (el máximo matemático es p−1 = 6 para 7 variables). Esto nos da los eigenvalores — una medida de cuánta información “absorbe” cada factor. Al verlos todos juntos podemos decidir con criterio cuántos son útiles.

K_max <- ncol(datos_iter) - 1   # = 6

ajuste_max <- fa(datos_iter,
                  nfactors = K_max,
                  rotate   = "none",
                  fm       = metodo)

eigen_df <- data.frame(
  Factor     = paste0("F", 1:K_max),
  Eigenvalue = ajuste_max$values[1:K_max],
  Var_Expl   = (ajuste_max$values[1:K_max] /
                  sum(abs(ajuste_max$values[1:K_max]))) * 100
) %>%
  mutate(Var_Acum = cumsum(Var_Expl))

eigen_df %>%
  mutate(across(where(is.numeric), ~ round(., 3))) %>%
  datatable(
    caption  = "Eigenvalores — todos los factores posibles",
    options  = list(pageLength = K_max, dom = "t"),
    rownames = FALSE,
    class    = "stripe hover"
  )
cat("\nCommunalidades con", K_max, "factores:\n")
## 
## Communalidades con 6 factores:
print(round(ajuste_max$communality, 3))
##      math_score   history_score   physics_score chemistry_score   biology_score 
##           0.193           0.199           0.197           0.155           0.144 
##   english_score geography_score 
##           0.160           0.110

Selección del número óptimo de factores

¿Cuántos factores necesitamos realmente?
Dos criterios clásicos nos guían:

Criterio de Kaiser: Conservar los factores con eigenvalor > 1. La lógica es que un factor debe “explicar más varianza que una sola variable original”.
Análisis Paralelo: Más sofisticado. Genera 500 matrices de datos aleatorios del mismo tamaño y compara sus eigenvalores con los nuestros. Solo retenemos factores cuyos eigenvalores superan a los aleatorios — si un factor no aporta más información que el azar, no lo conservamos.

set.seed(2024)
paralelo <- fa.parallel(datos_iter,
                         fm   = metodo,
                         fa   = "fa",
                         main = "Análisis Paralelo — Línea azul = datos reales | Línea roja = azar")

## Parallel analysis suggests that the number of factors =  0  and the number of components =  NA
cat("\nFactores sugeridos por Análisis Paralelo:", paralelo$nfact, "\n")
## 
## Factores sugeridos por Análisis Paralelo: 0

Scree Plot

¿Qué es el Scree Plot?
Es un gráfico que muestra los eigenvalores en orden descendente. Buscamos el “codo” — el punto donde la curva se aplana abruptamente. Los factores antes del codo son los informativos; los que siguen son principalmente ruido estadístico.

eigen_vals <- ajuste_max$values[1:K_max]
scree_df <- data.frame(
  Factor     = factor(paste0("F", 1:K_max), levels = paste0("F", 1:K_max)),
  Eigenvalue = eigen_vals,
  Tipo       = ifelse(eigen_vals > 1, "Eigenvalor > 1 (Kaiser)", "Eigenvalor ≤ 1")
)

p_scree <- ggplot(scree_df, aes(x = Factor, y = Eigenvalue, group = 1,
  text = paste0("Factor: ", Factor, "<br>Eigenvalor: ", round(Eigenvalue, 3)))) +
  geom_hline(yintercept = 1, linetype = "dashed", color = "#f59e0b", linewidth = 0.8) +
  geom_line(color = "#3a7bd5", linewidth = 1.2) +
  geom_point(aes(color = Tipo), size = 4) +
  scale_color_manual(values = c("Eigenvalor > 1 (Kaiser)" = "#22c55e",
                                "Eigenvalor ≤ 1" = "#94a3b8")) +
  annotate("text", x = 5.5, y = 1.08, label = "Criterio Kaiser (λ = 1)",
           color = "#f59e0b", size = 3.5, fontface = "bold") +
  labs(title = "Scree Plot — Eigenvalores por factor",
       x = "Factor", y = "Eigenvalor", color = "") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"),
        legend.position = "bottom")

ggplotly(p_scree, tooltip = "text") %>%
  layout(title = list(text = "<b>Scree Plot — Eigenvalores por factor</b>",
                      font = list(size = 14)),
         legend = list(orientation = "h", y = -0.2))
# Análisis Paralelo sugiere K = 1; Kaiser retiene K = 2 (F2 eigenvalor > 1).
# Se elige K = 2 por interpretabilidad sustantiva.
K <- 2
cat("Número de factores seleccionado: K =", K, "\n")
## Número de factores seleccionado: K = 2
cat("Criterio: Kaiser (eigenvalor > 1) + interpretabilidad sustantiva\n")
## Criterio: Kaiser (eigenvalor > 1) + interpretabilidad sustantiva
cat("Justificación: F1 y F2 tienen eigenvalores > 1 y separan ciencias vs humanidades.\n")
## Justificación: F1 y F2 tienen eigenvalores > 1 y separan ciencias vs humanidades.

Nota metodológica: El Análisis Paralelo sugiere K = 1. Sin embargo, Kaiser retiene K = 2 (F1 = 1.59, F2 = 1.04). Se elige K = 2 porque: (1) F2 supera el umbral de Kaiser, (2) tiene sentido interpretativo real (ciencias exactas vs. humanidades), y (3) el Análisis Paralelo con correlaciones muy débiles tiende a ser conservador.


#️ Estimación del modelo con K = 2

qué son las cargas factoriales?
Una vez decidido el número de factores, estimamos el modelo. El resultado principal son las cargas factoriales: un número entre -1 y 1 que indica qué tan fuertemente cada materia está asociada con cada factor. Una carga de 0.70 significa una asociación fuerte; una de 0.10 es casi nula. Típicamente se considera relevante cualquier carga con valor absoluto > 0.25.

ajuste <- fa(datos_iter,
              nfactors = K,
              rotate   = "none",
              fm       = metodo)

cat("--- Cargas factoriales sin rotación (cutoff = 0.20) ---\n")
## --- Cargas factoriales sin rotación (cutoff = 0.20) ---
print(ajuste$loadings, cutoff = 0.20)
## 
## Loadings:
##                 MR1    MR2   
## math_score       0.359       
## history_score    0.367 -0.242
## physics_score    0.335  0.265
## chemistry_score  0.335       
## biology_score    0.315       
## english_score    0.300       
## geography_score  0.230       
## 
##                  MR1   MR2
## SS loadings    0.730 0.194
## Proportion Var 0.104 0.028
## Cumulative Var 0.104 0.132
cat("\nVarianza explicada por factor:\n")
## 
## Varianza explicada por factor:
print(round(ajuste$Vaccounted, 3))
##                         MR1   MR2
## SS loadings           0.730 0.194
## Proportion Var        0.104 0.028
## Cumulative Var        0.104 0.132
## Proportion Explained  0.790 0.210
## Cumulative Proportion 0.790 1.000
cat("\nCommunalidades (proporción de varianza explicada de cada materia):\n")
## 
## Communalidades (proporción de varianza explicada de cada materia):
print(round(ajuste$communality, 3))
##      math_score   history_score   physics_score chemistry_score   biology_score 
##           0.137           0.193           0.182           0.114           0.118 
##   english_score geography_score 
##           0.117           0.062
fa.diagram(ajuste,
           main = "AF sin rotación — 2 factores óptimos",
           col  = "#1a3a5c", cut = 0.20)

Communalidades: Indican qué proporción de la varianza de cada materia es explicada por los 2 factores. Valores bajos (< 0.30) sugieren que esa materia no encaja bien en el modelo — no hay ninguna en este caso.


Rotación de factores

¿Para qué sirve la rotación?
Imagina que tienes dos focos iluminando un objeto desde ángulos distintos. La rotación es como ajustar el ángulo de los focos para que cada uno ilumine una parte diferente y bien definida del objeto. En términos estadísticos, la rotación reorganiza matemáticamente los factores para que cada materia cargue fuertemente en uno de ellos y débilmente en los demás, facilitando la interpretación.

Varimax (ortogonal): Los factores resultantes son completamente independientes entre sí (correlación = 0). Más sencillo de interpretar.
Promax (oblicua): Permite que los factores estén algo correlacionados. Más realista cuando los constructos medidos tienen relación natural.

Rotación Varimax (factores independientes)

ajuste_varimax <- fa(r        = cor(datos_iter),
                      n.obs    = nrow(datos_iter),
                      nfactors = K,
                      rotate   = "varimax",
                      fm       = metodo)

cat("=== Cargas factoriales — Varimax ===\n")
## === Cargas factoriales — Varimax ===
print(ajuste_varimax$loadings, cutoff = 0.20)
## 
## Loadings:
##                 MR1   MR2  
## math_score      0.317      
## history_score   0.430      
## physics_score         0.424
## chemistry_score 0.201 0.272
## biology_score         0.319
## english_score   0.329      
## geography_score       0.231
## 
##                  MR1   MR2
## SS loadings    0.462 0.461
## Proportion Var 0.066 0.066
## Cumulative Var 0.066 0.132
fa.diagram(ajuste_varimax,
           main = "Rotación Varimax — 2 factores",
           col  = "#1a56b0", cut = 0.20)

Rotación Promax (factores que pueden correlacionarse)

ajuste_promax <- fa(r        = cor(datos_iter),
                     n.obs    = nrow(datos_iter),
                     nfactors = K,
                     rotate   = "promax",
                     fm       = metodo)

cat("=== Cargas factoriales — Promax (Matriz Patrón) ===\n")
## === Cargas factoriales — Promax (Matriz Patrón) ===
print(ajuste_promax$loadings, cutoff = 0.20)
## 
## Loadings:
##                 MR1    MR2   
## math_score       0.301       
## history_score    0.478       
## physics_score           0.485
## chemistry_score         0.243
## biology_score           0.329
## english_score    0.355       
## geography_score         0.237
## 
##                  MR1   MR2
## SS loadings    0.475 0.474
## Proportion Var 0.068 0.068
## Cumulative Var 0.068 0.136
fa.diagram(ajuste_promax,
           main = "Rotación Promax — 2 factores",
           col  = "#1a56b0", cut = 0.20)

cat("\nCorrelación entre factores (Phi):\n")
## 
## Correlación entre factores (Phi):
print(round(ajuste_promax$Phi, 3))
##       MR1   MR2
## MR1 1.000 0.608
## MR2 0.608 1.000

Comparativo de ajuste entre rotaciones

cat("=== Comparativo de ajuste ===\n\n")
## === Comparativo de ajuste ===
rmsea_v <- if (!is.null(ajuste_varimax$RMSEA)) round(ajuste_varimax$RMSEA[1], 4) else NA
rmsea_p <- if (!is.null(ajuste_promax$RMSEA))  round(ajuste_promax$RMSEA[1],  4) else NA
bic_v   <- if (!is.null(ajuste_varimax$BIC))   round(ajuste_varimax$BIC, 3) else NA
bic_p   <- if (!is.null(ajuste_promax$BIC))    round(ajuste_promax$BIC,  3) else NA

cat("RMSEA — Varimax:", rmsea_v, " | RMSEA — Promax:", rmsea_p, "\n")
## RMSEA — Varimax: 0.0098  | RMSEA — Promax: 0.0098
cat("BIC   — Varimax:", bic_v,   " | BIC   — Promax:", bic_p,   "\n\n")
## BIC   — Varimax: -51.255  | BIC   — Promax: -51.255
phi_val <- if (!is.null(ajuste_promax$Phi)) abs(ajuste_promax$Phi[1, 2]) else 0

if (phi_val < 0.30) {
  cat("Phi =", round(phi_val, 3), "< 0.30 → los factores son prácticamente independientes.\n")
  cat(" Decisión: se selecciona VARIMAX como rotación final.\n")
} else {
  cat("Phi =", round(phi_val, 3), "≥ 0.30 → correlación real entre factores.\n")
  cat("Decisión: se selecciona PROMAX como rotación final.\n")
}
## Phi = 0.608 ≥ 0.30 → correlación real entre factores.
## Decisión: se selecciona PROMAX como rotación final.

️ Interpretación de los factores

¿Qué “son” los factores que encontramos?
Llegamos a la parte más interesante: darle nombre y significado a los factores descubiertos. Para eso miramos las materias con cargas altas (> 0.25 en valor absoluto) en cada factor. El nombre del factor debe reflejar el elemento común de esas materias: ¿qué habilidad o aptitud comparten?

modelo_final <- if (!is.null(ajuste_promax$Phi) && abs(ajuste_promax$Phi[1, 2]) >= 0.30) {
  ajuste_promax
} else {
  ajuste_varimax
}

cat("Modelo seleccionado:",
    ifelse(identical(modelo_final, ajuste_varimax), "Varimax", "Promax"), "\n\n")
## Modelo seleccionado: Promax
cat("=== Cargas factoriales finales (cutoff = 0.25) ===\n")
## === Cargas factoriales finales (cutoff = 0.25) ===
print(modelo_final$loadings, cutoff = 0.25)
## 
## Loadings:
##                 MR1    MR2   
## math_score       0.301       
## history_score    0.478       
## physics_score           0.485
## chemistry_score              
## biology_score           0.329
## english_score    0.355       
## geography_score              
## 
##                  MR1   MR2
## SS loadings    0.475 0.474
## Proportion Var 0.068 0.068
## Cumulative Var 0.068 0.136
cat("\n=== Varianza total explicada ===\n")
## 
## === Varianza total explicada ===
print(round(modelo_final$Vaccounted, 3))
##                         MR1   MR2
## SS loadings           0.462 0.461
## Proportion Var        0.066 0.066
## Cumulative Var        0.066 0.132
## Proportion Explained  0.501 0.499
## Cumulative Proportion 0.501 1.000
interpretacion <- data.frame(
  Factor = paste0("Factor ", seq_len(K)),
  Materias_dominantes = c(
    "history, math, english  (lenguaje / pensamiento abstracto)",
    "physics, biology, chemistry  (ciencias exactas / naturales)"
  )[seq_len(K)],
  Nombre_propuesto = c(
    "Aptitud Académica General",
    "Aptitud en Ciencias Exactas"
  )[seq_len(K)],
  Interpretacion = c(
    "Refleja un rendimiento académico general transversal: estudiantes que tienden a desempeñarse bien en todas las materias.",
    "Captura una habilidad específica para las ciencias exactas y naturales, más allá del desempeño general."
  )[seq_len(K)]
)

kable(interpretacion,
      caption = "Interpretación de los factores latentes",
      col.names = c("Factor", "Materias con carga alta", "Nombre propuesto", "Significado"))
Interpretación de los factores latentes
Factor Materias con carga alta Nombre propuesto Significado
Factor 1 history, math, english (lenguaje / pensamiento abstracto) Aptitud Académica General Refleja un rendimiento académico general transversal: estudiantes que tienden a desempeñarse bien en todas las materias.
Factor 2 physics, biology, chemistry (ciencias exactas / naturales) Aptitud en Ciencias Exactas Captura una habilidad específica para las ciencias exactas y naturales, más allá del desempeño general.

Hallazgo clave: Los 2 factores separan dos tipos de aptitud en los estudiantes:
Factor 1 — Aptitud General: Todas las materias cargan positivamente, pero history, math y english tienen las cargas más altas. Representa un rendimiento generalizado.
Factor 2 — Ciencias Exactas: Physics, biology y chemistry dominan este factor. Representa una habilidad diferenciada para el pensamiento científico cuantitativo.


Puntajes factoriales por estudiante

¿Qué son los puntajes factoriales?
Una vez que los factores están definidos, podemos calcular un puntaje para cada estudiante en cada factor. Es como traducir sus 7 calificaciones a 2 “super-notas”:
• Su puntaje en el Factor 1 (Aptitud General)
• Su puntaje en el Factor 2 (Ciencias Exactas)

Estos puntajes estandarizados tienen media 0 y desviación estándar 1: un puntaje de +1 significa que el estudiante está 1 desviación estándar por encima del promedio en esa aptitud.

scores_res <- factor.scores(x      = datos_iter,
                              f      = modelo_final,
                              method = "regression")

puntajes <- as.data.frame(scores_res$scores)
colnames(puntajes) <- paste0("Factor_", seq_len(K))

cat("Primeros puntajes factoriales por estudiante:\n")
## Primeros puntajes factoriales por estudiante:
head(puntajes)
##       Factor_1    Factor_2
## 1 -0.002442431  0.23520829
## 2  0.897619010  1.16938716
## 3  0.561832158  0.61356526
## 4 -0.577578525  0.03628598
## 5 -0.544892765 -0.78982031
## 6  0.489152544 -0.19550960
cat("\nEstadísticos de los puntajes (media ≈ 0, SD ≈ 1):\n")
## 
## Estadísticos de los puntajes (media ≈ 0, SD ≈ 1):
summary(puntajes)
##     Factor_1           Factor_2       
##  Min.   :-2.52846   Min.   :-2.03366  
##  1st Qu.:-0.38767   1st Qu.:-0.39490  
##  Median : 0.02788   Median :-0.01415  
##  Mean   : 0.00000   Mean   : 0.00000  
##  3rd Qu.: 0.43280   3rd Qu.: 0.40507  
##  Max.   : 1.50461   Max.   : 1.57489

Contraste con aspiración de carrera

¿Para qué contrasta con career_aspiration?
Esta es la prueba de validez más intuitiva del análisis: si los factores latentes que descubrimos realmente capturan habilidades reales, entonces deberían distinguir entre estudiantes con diferentes aspiraciones de carrera. Por ejemplo, si el Factor 2 mide “aptitud en ciencias exactas”, los futuros médicos o científicos deberían tener puntajes distintos a los futuros artistas o empresarios. Si el contraste muestra diferencias claras, los factores son significativos en la práctica.

datos_contraste <- cbind(
  career_aspiration = datos_raw$career_aspiration,
  puntajes
)

orden_carreras <- datos_contraste %>%
  group_by(career_aspiration) %>%
  summarise(med = median(Factor_1), .groups = "drop") %>%
  arrange(med) %>%
  pull(career_aspiration)

datos_contraste$career_aspiration <- factor(
  datos_contraste$career_aspiration, levels = orden_carreras
)

# Factor 1
p_f1 <- ggplot(datos_contraste,
       aes(x = career_aspiration, y = Factor_1, fill = career_aspiration,
           text = paste0("Carrera: ", career_aspiration,
                         "<br>Puntaje F1: ", round(Factor_1, 2)))) +
  geom_boxplot(show.legend = FALSE, alpha = 0.8, outlier.size = 0.8) +
  coord_flip() +
  geom_hline(yintercept = 0, linetype = "dashed", color = "#64748b", linewidth = 0.6) +
  scale_fill_viridis_d(option = "mako", begin = 0.15, end = 0.85) +
  labs(title = "Factor 1 (Aptitud General) por aspiración de carrera",
       subtitle = "Línea punteada = promedio global (0)",
       x = "Aspiración de carrera",
       y = "Puntaje Factor 1") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

ggplotly(p_f1, tooltip = "text") %>%
  layout(title = list(text = "<b>Factor 1 — Aptitud General por carrera</b>",
                      font = list(size = 13)))
if (K >= 2) {
  orden_f2 <- datos_contraste %>%
    group_by(career_aspiration) %>%
    summarise(med = median(Factor_2), .groups = "drop") %>%
    arrange(med) %>%
    pull(career_aspiration)

  datos_contraste$career_aspiration <- factor(
    datos_contraste$career_aspiration, levels = orden_f2
  )

  p_f2 <- ggplot(datos_contraste,
         aes(x = career_aspiration, y = Factor_2, fill = career_aspiration,
             text = paste0("Carrera: ", career_aspiration,
                           "<br>Puntaje F2: ", round(Factor_2, 2)))) +
    geom_boxplot(show.legend = FALSE, alpha = 0.8, outlier.size = 0.8) +
    coord_flip() +
    geom_hline(yintercept = 0, linetype = "dashed", color = "#64748b", linewidth = 0.6) +
    scale_fill_viridis_d(option = "plasma", begin = 0.15, end = 0.85) +
    labs(title = "Factor 2 (Ciencias Exactas) por aspiración de carrera",
         subtitle = "Línea punteada = promedio global (0)",
         x = "Aspiración de carrera",
         y = "Puntaje Factor 2") +
    theme_minimal(base_size = 12) +
    theme(plot.title = element_text(face = "bold"))

  ggplotly(p_f2, tooltip = "text") %>%
    layout(title = list(text = "<b>Factor 2 — Ciencias Exactas por carrera</b>",
                        font = list(size = 13)))
}

Espacio factorial — las 6 carreras más frecuentes

¿Qué muestra este diagrama de dispersión?
Cada punto es un estudiante, ubicado según sus puntajes en los dos factores. Las elipses enmarcan el 68% central de los estudiantes de cada carrera. Si las elipses se separan claramente, significa que los factores discriminan bien entre aspiraciones. Si se superponen mucho, los factores no distinguen esas carreras.

if (K >= 2) {
  top_carreras <- datos_raw %>%
    count(career_aspiration) %>%
    slice_max(n, n = 6) %>%
    pull(career_aspiration)

  df_scatter <- datos_contraste %>%
    filter(as.character(career_aspiration) %in% top_carreras)

  p_scat <- ggplot(df_scatter,
       aes(x = Factor_1, y = Factor_2,
           color = career_aspiration, shape = career_aspiration,
           text = paste0("Carrera: ", career_aspiration,
                         "<br>Factor 1: ", round(Factor_1, 2),
                         "<br>Factor 2: ", round(Factor_2, 2)))) +
    geom_point(alpha = 0.45, size = 1.5) +
    stat_ellipse(level = 0.68, linewidth = 0.9, linetype = "solid") +
    scale_color_brewer(palette = "Set1") +
    geom_hline(yintercept = 0, color = "#e2e8f0") +
    geom_vline(xintercept = 0, color = "#e2e8f0") +
    labs(title = "Espacio factorial — 6 carreras más frecuentes",
         subtitle = "Elipses al 68% de confianza por grupo",
         x = "Factor 1 — Aptitud General",
         y = "Factor 2 — Ciencias Exactas",
         color = "Carrera", shape = "Carrera") +
    theme_minimal(base_size = 12) +
    theme(plot.title = element_text(face = "bold"),
          legend.position = "right")

  ggplotly(p_scat, tooltip = "text") %>%
    layout(title = list(text = "<b>Espacio factorial — 6 carreras más frecuentes</b>",
                        font = list(size = 13)))
}

Conclusiones

Síntesis del análisis

01

Normalidad multivariada
La prueba de Mardia rechazó la normalidad multivariada (n = 2 000). Con muestras grandes esto es común. Justificó el uso de minres como método de estimación.

02

Estructura de correlaciones
Todas las correlaciones entre materias son positivas y significativas, pero débiles (r entre 0.05 y 0.15). Existe estructura, aunque tenue — explicación total ~ 13 %.

03

Adecuación de la muestra
Bartlett rechazó H₀ con p < 0.0001. KMO global = 0.66 (mediocre-regular). Todas las variables se conservaron.

04

Número de factores
Análisis Paralelo sugirió K = 1; Kaiser retiene K = 2 (F2 con eigenvalor = 1.04). Se eligió K = 2 por interpretabilidad sustantiva.

05

Interpretación de factores
Factor 1 Aptitud académica general (todas las materias cargan positivamente).
Factor 2 Aptitud en ciencias exactas (física, química, biología dominan).

06

Validez con aspiración de carrera
Estudiantes que aspiran a ser médicos o científicos tienen puntajes más altos en F2. Business Owner tiende a F1 alto. Los factores discriminan parcialmente entre carreras, validando su pertinencia real.

Conclusión final :

Detrás de las 7 calificaciones de estos estudiantes se esconden dos habilidades fundamentales: una aptitud académica general y una aptitud específica para las ciencias exactas. Estas habilidades no son directamente observables en ninguna calificación individual, pero emergen del patrón conjunto de notas. Lo más valioso es que estos factores tienen significado real: los estudiantes con mayor aptitud en ciencias exactas tienden a aspirar a carreras como medicina o ciencias, mientras que los de alta aptitud general se orientan más hacia negocios o actividades diversificadas.


Referencias metodológicas

  • Swiss analisis factorial.Rmd — estructura del AFE, corrplot, dendrograma, rotaciones Varimax/Promax, puntajes factoriales.
  • Datos Normales.Rmd — análisis paralelo con fa.parallel, patrón iterativo de adecuación de muestra.
  • Personalidad Analisis Factorial policoricas.Rmd — referencia para variables ordinales.