¿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.
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¿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
## Número de variables : 17
## '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.
¿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
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.
## 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
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)))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_boxpor 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):
## math_score history_score physics_score chemistry_score biology_score
## 0 0 0 0 0
## english_score geography_score
## 0 0
##
## Verificación — desviaciones estándar (deben ser ≈ 1):
## 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
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.
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?).
##
## === Prueba de Mardia — Normalidad Multivariada ===
## Asimetría multivariada (b1p) : 2.4685
## p-value asimetría : 0
## Curtosis multivariada (b2p) : 61.4601
## 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.
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)")| 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 |
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)))¿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.
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).
## ====== 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.
## --- Índice KMO ---
## 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
##
## KMO global = 0.6581
##
## → Clasificación: Mediocre-Regular (>0.60, aceptable para proceder)
## → 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.
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.
## Método seleccionado: minres
## Justificación: normalidad rechazada (Mardia) + correlaciones débiles → minres
¿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"
)##
## Communalidades con 6 factores:
## 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
¿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
##
## Factores sugeridos por Análisis Paralelo: 0
¿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
## Criterio: Kaiser (eigenvalor > 1) + interpretabilidad sustantiva
## 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) ---
##
## 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
##
## Varianza explicada por factor:
## 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
##
## Communalidades (proporción de varianza explicada de cada materia):
## 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
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.
¿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.
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 ===
##
## 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
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) ===
##
## 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
##
## Correlación entre factores (Phi):
## MR1 MR2
## MR1 1.000 0.608
## MR2 0.608 1.000
## === 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
## 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.
¿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
## === Cargas factoriales finales (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
##
## === Varianza total explicada ===
## 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"))| 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.
¿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:
## 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
##
## Estadísticos de los puntajes (media ≈ 0, SD ≈ 1):
## 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
¿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)))
}¿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)))
}Síntesis del análisis
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.
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 %.
Adecuación de la muestra
Bartlett rechazó H₀ con
p < 0.0001. KMO global = 0.66 (mediocre-regular). Todas las variables
se conservaron.
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.
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).
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.
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.