Ocho análisis DIF en R, paso a paso
Guía de implementación de los análisis de funcionamiento diferencial del reactivo usados en mi tesis de maestría
2024-03-01
Ocho análisis DIF en R, paso a paso
Cuando empecé la parte analítica de mi tesis, entender qué hacía cada método de detección de funcionamiento diferencial del reactivo me costó bastante menos que conseguir que corrieran. La literatura describe los procedimientos con precisión y los paquetes de R los implementan bien, pero entre una cosa y la otra hay un trecho que casi nadie documenta, el de qué forma exacta tiene que tener la base de datos, qué argumento espera cada función, cómo se automatiza una comparación por pares sin escribirla seis veces y cómo se pasa de la salida cruda a una tabla que alguien pueda leer.
Esta guía intenta recorrer ese trecho, pues sigue los ocho análisis DIF que hice, en el orden en que los hice, con el código que usé para cada uno y una explicación de qué hace cada paso y por qué.
Alcance de este documento
Esto es una guía de implementación, no el estudio. Aquí se explica cómo ejecutar cada procedimiento en R; no se discuten los resultados sustantivos, la selección del modelo ni las decisiones de validez que se siguen de ellos.
Esas discusiones están en la tesis: Díaz García, D. E. (2024). El papel del funcionamiento diferencial del reactivo (DIF) en la validez de exámenes de alto impacto [Tesis de maestría, Universidad Nacional Autónoma de México]. Consultar en TESIUNAM.
Para la comparación entre lo que arrojó cada método y por qué divergen, está el artículo Ocho análisis, ocho respuestas distintas.
Sobre los datos
El estudio original se hizo con microdatos reales de un examen de comprensión lectora para posgrado, que no son públicos ni se distribuyen aquí: contienen información a nivel de sustentante y permiten recuperar la clave del examen.
Para que la guía se pueda seguir, el bloque de la siguiente sección genera un conjunto de datos sintéticos con la misma estructura de columnas, un modelo 2PL de fondo y DIF sembrado deliberadamente en tres reactivos. El resto del código está pensado para correr sobre esos datos; la excepción es
DIFRasch(), que necesita una base con los reactivos ya calificados (ver la nota de esa sección). Los números que verás no son los de la tesis.
Paquetes utilizados
Para los análisis de la tesis se utilizaron los siguientes paquetes de R; DIFtree, WrightMap y psychotree se cargan más adelante, en la sección que los usa:
- library(data.table)
- library(psych)
- library(psy)
- library(mirt)
- library(sjPlot)
- library(difR)
- library(ltm)
- library(lme4)
- library(lordif)
- library(ShinyItemAnalysis)
- library(tidyverse)
- library(gt)
- library(kableExtra)
- library(corrplot)
- library(RColorBrewer)
- library(eRm)
- library(ggdist)
Manejo de los datos
library(data.table)
library(psych)
library(psy)
library(mirt)
library(sjPlot)
library(difR)
library(ltm)
library(lme4)
library(lordif)
library(ShinyItemAnalysis)
library(tidyverse)
library(gt)
library(kableExtra)
library(corrplot)
library(RColorBrewer)
library(eRm)
library(ggdist)Generación de los datos sintéticos
El estudio original trabajó con tres bases derivadas de los microdatos del examen. Como esos datos no son públicos, el siguiente bloque las reconstruye por simulación, conservando la estructura exacta que espera el resto de la guía:
lectura— la base completa: campos administrativos (area,posgrado,periodo,ID,edad,genero,nacionalidad,nivel,lic_proced,lic_UNAM,plantel_UNAM) seguidos de los once reactivos con la opción elegida por cada sustentante.lectura_dicotomicos— las covariables de agrupación más los once reactivos ya calificados como acierto o error.reactivos_lectura— únicamente los once reactivos calificados.
Las respuestas se generan bajo un modelo de dos parámetros, y el DIF se siembra deliberadamente: a los reactivos 4, 6 y 10 se les suma dificultad para los sustentantes de las áreas 3 y 4. Ese es el efecto que los métodos de las secciones siguientes deberían recuperar, y tener la respuesta correcta de antemano es justamente lo que permite comprobar que cada procedimiento está bien implementado. La siembra imita lo que muestran los datos de la tesis al comparar CFMI y CBQS contra CS y HA: ahí, el 4, el 6 y el 10 son los que señalan tres de los cuatro métodos tradicionales (Tabla 9 de la tesis), y los tres resultaron más difíciles para ciencias sociales y humanidades. Al cruzar todos los métodos, en cambio, los más consistentes fueron el 4, el 5 y el 6. La clave del examen tampoco se reproduce: en la base con las opciones elegidas, el acierto aparece como «X».
set.seed(2024)
## --- Estructura de la muestra -----------------------------------------
## Se replican los tamaños por área del estudio original.
n_area <- c(147, 417, 427, 227)
area <- rep(1:4, times = n_area)
N <- length(area)
J <- 11
items <- paste0("LR", 1:J)
## --- Parámetros de los reactivos (modelo 2PL) -------------------------
## a = discriminación, b = dificultad.
a <- c(1.02, 0.61, 0.88, 1.34, 0.95, 1.21, 0.77, 1.10, 0.54, 0.92, 1.05)
b <- c(-0.85, 0.42, -0.10, 0.61, -0.35, 0.78, 0.05, -0.52, -1.20, 0.28, 0.15)
## --- DIF sembrado ------------------------------------------------------
items_dif <- c(4, 6, 10)
magnitud <- 0.6 # en logits
## --- Simulación de respuestas -----------------------------------------
theta <- rnorm(N, mean = 0, sd = 1)
resp <- matrix(NA_integer_, nrow = N, ncol = J, dimnames = list(NULL, items))
for (j in seq_len(J)) {
b_j <- rep(b[j], N)
if (j %in% items_dif) {
b_j <- b_j + ifelse(area %in% c(3, 4), magnitud, 0)
}
p <- 1 / (1 + exp(-a[j] * (theta - b_j)))
resp[, j] <- rbinom(N, size = 1, prob = p)
}
## --- Covariables administrativas --------------------------------------
posgrado <- sprintf("%02d", sample(1:12, N, replace = TRUE))
genero <- sample(1:2, N, replace = TRUE)
nivel <- sample(1:2, N, replace = TRUE, prob = c(0.25, 0.75))
edad <- round(rnorm(N, mean = 27, sd = 4))
## --- Las tres bases que usa el resto de la guía -----------------------
## 1. Solo los reactivos, calificados
reactivos_lectura <- as.data.frame(resp)
## 2. Reactivos más covariables de agrupación
lectura_dicotomicos <- data.frame(area, posgrado, edad, genero, nivel, resp)
## 3. Base completa con la opción elegida. Se reconstruye a partir del
## acierto: si el sustentante acertó se le asigna la clave, si no, un
## distractor al azar. La clave es ficticia a propósito: todos los
## aciertos se marcan con "X" y los errores con una opción de la A a la D,
## para que nadie la confunda con la del examen original.
clave <- rep("X", J)
opciones <- c("A", "B", "C", "D")
crudas <- vapply(seq_len(J), function(j) {
distractores <- setdiff(opciones, clave[j])
ifelse(resp[, j] == 1L, clave[j], sample(distractores, N, replace = TRUE))
}, character(N))
colnames(crudas) <- items
lectura <- data.frame(
area,
posgrado,
periodo = "02",
ID = sprintf("%05d", seq_len(N)),
edad,
genero,
nacionalidad = NA_character_,
nivel,
lic_proced = NA_character_,
lic_UNAM = NA_character_,
plantel_UNAM = NA_character_,
crudas,
stringsAsFactors = FALSE
)
dim(lectura); dim(lectura_dicotomicos); dim(reactivos_lectura)Una función para tablas con formato APA
Buena parte del trabajo de reportar resultados es repetitivo. Conviene definir una sola vez la función que da formato a las tablas y reutilizarla en todo el documento:
## Tabla estilo APA
apa <- function(x, title = " ",...) {
gt(x,...) %>%
tab_options(
table.border.top.color = "white",
heading.title.font.size = px(16),
column_labels.border.top.width = 3,
column_labels.border.top.color = "black",
column_labels.border.bottom.width = 3,
column_labels.border.bottom.color = "black",
table_body.border.bottom.color = "black",
table.border.bottom.color = "white",
table.width = pct(100),
table.background.color = "white"
) %>%
cols_align(align="center") %>%
tab_style(
style = list(
cell_borders(
sides = c("top", "bottom"),
color = "white",
weight = px(1)
),
cell_text(
align="center"
),
cell_fill(color = "white", alpha = NULL)
),
locations = cells_body(
columns = everything(),
rows = everything()
)
) %>%
#title setup
tab_header(
title = gt::html("<i>", title, "</i>")
) %>%
opt_align_table_header(align = "left")
}
Análisis descriptivos para examen de comprensión lectora
Distribución de puntajes totales
Para explorar de forma descriptiva la distribución de puntajes de los sustentantes dentro de cada una de las áreas de posgrado, se comenzó por una gráfica de lluvia (raincloud plot). La ventaja de este tipo de gráfica es que superpone tres representaciones de la misma variable: la densidad de la distribución en forma de campana, un diagrama de caja con la mediana y los cuartiles, y la nube de puntos individuales. Así se ve a la vez la forma general de la distribución y la dispersión caso por caso, sin tener que elegir entre una y otra.
Para elaborar este tipo de gráfico a partir del puntaje total de la prueba se siguieron los siguientes pasos:
Sumar el puntaje total para cada sustentante
Asegurarse de que no hubiera datos faltantes en las variables a utilizar (puntaje total y género)
Generar la gráfica base, definiendo la distribución de las variables en los distintos ejes mediante el paquete ggplot2
Añadir la distribución de respuestas en forma de campana
Añadir el diagrama de caja
Añadir el gráfico de dispersión
Especificar los límites a incluir dentro de los ejes
Definir el tema del gráfico para personalizarlo
Añadir etiquetas al gráfico
Personalizar la paleta de colores utilizada
Los pasos recién descritos corresponden uno a uno con las líneas del siguiente bloque de código utilizado para generar el gráfico.
## Figura 1 - Distribución de puntajes por área
lectura_dicotomicos%>%
mutate(Total = rowSums(reactivos_lectura))%>%
drop_na(genero)%>%
mutate(area = factor(area))%>%
ggplot(aes(x = area, y = Total, color = area)) +
ggdist::stat_halfeye(adjust =.8, width = .5, justification = -.5, aes(fill=area)) +
geom_boxplot(width = .25, outlier.shape = NA) +
geom_point(size = 1.3, alpha = .4, position = position_jitter(seed =1, width = .4)) +
coord_cartesian(xlim = c(1.2, NA)) +
theme_light() +
labs(title = "Figura X. Distribución de puntajes en examen de lectura y gramática por área de posgrado",
x = "Área", y = "Puntaje total")+
scale_fill_manual(values = c("#44BEFE", "#098BD1", "#0D547A", "#092A3C"))+
scale_color_manual(values = c("#44BEFE", "#098BD1", "#0D547A", "#092A3C"))Porcentaje de respuestas correctas por reactivo
Una vez observados los resultados a nivel de puntajes totales por área, para visualizar las diferencias a nivel de porcentaje de respuestas correctas por reactivo por área, se llevaron a cabo los siguientes pasos:
Definir una función personalizada para calcular porcentajes de respuestas correctas
Eliminar valores perdidos
Agrupar los datos por área (permite que los análisis posteriores se realicen de forma diferenciada para cada área).
Calcular el porcentaje de respuestas correctas
Modificar la estructura de los resultados para que, en lugar de tener una columna por reactivo, se tuviera una columna con cada reactivo en una fila y una columna con el porcentaje de respuestas correctas del reactivo correspondiente en cada fila.
Modificar los resultados para que, en lugar de presentarse en formato decimal, se presenten en porcentajes con un decimal
Generar la base del gráfico en ggplot2, asignando el nombre de los reactivos al eje de las x, el porcentaje de respuestas correctas al eje de las y, y el área de posgrado al color de las columnas (generando así 4 columnas para cada reactivo en el mismo gráfico).
Definir el gráfico de columnas mediante
geom_col(), definiendo que las columnas se distribuyan una junto a la otra en lugar de una encima de la otra.Definir el tema del gráfico para personalizarlo
Añadir etiquetas al gráfico
Personalizar la paleta de colores utilizada
# Figura 2 - Porcentaje de respuestas correctas por área
reactivos_lectura%>%
cbind(lectura$area)%>%
mutate(area = factor(`lectura$area`))%>%
select(-`lectura$area`)%>%
drop_na()%>%
group_by(area)%>%
summarise(across(everything(), mean))%>%
pivot_longer(!area,
names_to = "reactivo", values_to = "porcentaje")%>%
mutate(porcentaje = round(porcentaje*100,1))%>%
ggplot(aes(x = factor(reactivo, levels = names(reactivos_lectura)), y =porcentaje, fill = area)) + geom_col(position = "dodge") +
theme_light() +
labs(title = "Figura X. Porcentaje de respuestas correctas por reactivo en examen de lectura y gramática por área de posgrado",
x = "Reactivo", y = "Porcentaje de respuestas correctas") +
scale_fill_manual(values = c("#44BEFE", "#098BD1", "#0D547A", "#092A3C"))
Análisis DIF con distintos métodos
Método de Mantel-Haenszel
Uno de los métodos más utilizados para detectar DIF tanto por su simplicidad como por su practicidad es el método de Mantel-Haenszel. En R, es posible realizar este análisis mediante la función difMH() perteneciente al paquete difR de Magis et al. (2010). Esta función permite la comparación directa entre dos grupos para la detección de DIF. Los principales parámetros que requiere son:
La base de datos que se va a analizar, conteniendo únicamente los reactivos a analizar y la variable correspondiente a la definición de los grupos a analizar. Cabe destacar que esta variable puede estar en una base de datos distinta siempre y cuando contenga la misma cantidad de filas y ambas bases de datos estén ordenadas de la misma forma, de manera que cada fila de la base de datos con reactivos corresponda a la misma fila en la base de datos con los grupos.
La especificación de la variable con los grupos en el argumento
groupEl nombre o identificador del grupo definido como focal dentro del argumento
focal.name. El nombre introducido en este argumento debe ser el mismo que se encuentra dentro de la variable definida en el argumento anterior. Es decir, si dentro de la variable, el grupo focal aparece como el número1, de esa misma manera debe definirse aquí; en cambio, si en la variable aparece como"focal", entonces se define de esa manera, incluyendo las comillas si se trata de una variable de tipo carácter.
Dados estos requerimientos, es necesario transformar las bases de datos con las que se ha trabajado hasta ahora. Para ejemplificar el uso básico de esta función, se presenta a continuación el análisis entre las áreas 1 y 2. Dentro del bloque de código se realizan las siguientes operaciones:
Generar una base de datos que solamente incluya los casos de las dos áreas a comparar utilizando la función
filter()del paquetedplyr.Generar una segunda base de datos a partir de esta que incluya únicamente los reactivos.
Ejecutar la función
difMHutilizando estas dos bases de datos, seleccionando al área 2 como grupo focal. Se guardan los resultados de dicho análisis en un objeto nuevo que llamaremosMHCalcular manualmente el estadístico MH D-DIF (−2.35 por el logaritmo de la razón de momios común, en la escala delta de ETS) a partir del estadístico alfa que devuelve
difMH().Convertir los resultados del paso anterior en una representación simbólica utilizando la función
symnum(). Los símbolos se asignan de acuerdo con los umbrales sugeridos por Zwick (2012).Crear una tabla final de resultados que incluye el nombre de cada reactivo, el valor de MH D-DIF y el símbolo correspondiente a su nivel de DIF.
lecturaDIF <- lectura%>%
dplyr::select(area)%>%
bind_cols(reactivos_lectura)
areas <- lecturaDIF%>%
filter(area %in% c(1,2))
reactivos <- areas%>%
select(!area)
MH<-difMH(reactivos, areas$area, focal.name= 2)
coef <- round(-2.35 * log(MH$alphaMH), 4)
DIF <- as.vector(symnum(coef, c(-Inf,-1.5,-1, 1, 1.5, Inf), symbols = c("--","-","", "+", "++")))
tabladif<-paste(coef, DIF, sep = " ")%>%as.data.frame()%>%
set_names("Area1 Vs Area2")%>%
bind_cols(reactivos = names(reactivos),. )
Para leer el signo hay que tener presente cuál es el grupo focal. alphaMH es la razón entre los momios de acertar del grupo de referencia y los del focal a igual puntaje total, así que un MH D-DIF negativo indica que el reactivo es más difícil para el grupo focal, aquí el área 2, y uno positivo, que lo es para el de referencia. Es la convención de ETS, y conviene revisar el focal.name antes de interpretar cualquier tabla de este método. En la tesis (p. 66) esta regla aparece invertida: con el primer grupo como referencia, un valor positivo indica que el reactivo es más sencillo para el segundo grupo, no para el primero.
Si bien este procedimiento es relativamente sencillo, hacerlo para cada uno de los pares se vuelve un proceso repetitivo que puede bien ser automatizado. Para ello, en el siguiente bloque de código replicamos el código de la sección anterior y lo encerramos dentro de un bucle del tipo for(). Más concretamente, en el siguiente bloque se realizan los siguientes pasos:
Se crea una lista que incluye cada una de las comparaciones como vectores individuales, de tal forma que
c(1,2)corresponde a la comparación entre las áreas 1 y 2. Esta lista nos será de utilidad para el resto de metodologías DIF que comparan únicamente dos grupos.Se abre el bucle for indicando que será un bucle entre 1 y la cantidad de objetos dentro de la lista, es decir, este bucle va a ejecutar las funciones que contenga un total de 6 veces, y el objeto
iva a tomar los valores entre 1 y 6.Se ejecuta el mismo código que en la sección anterior pero haciendo los reemplazos correspondientes para que funcione dentro de un bucle. En cada sección donde se definen los grupos a utilizar, en lugar de escribir de forma explícita las áreas, se hace referencia a ellas de la siguiente manera:
pairs_list[[i]][1]. Esta línea de código llama al objeto número i (el número de iteración dentro del bucle) y al primer objeto dentro de ese vector en la lista.Para finalizar el bucle, creamos una tabla con todos los resultados utilizando un doble condicional
if. Este condicional le indica al bucle que, si el objeto que nombraremosTablaDIF_CL_MHno existe dentro de nuestro ambiente de trabajo, lo defina como una base de datos con el nombre de nuestros reactivos en una columna y los resultados del bucle como otra columna. En cambio, si el objeto ya existe, simplemente pega los resultados como una columna más dentro de la base de datos. Nótese que se define primero el condicional para el caso en el que el objeto sí exista. Esto es simplemente para evitar que la primera iteración muestre resultados duplicados.Finalmente, para presentar los resultados, creamos una tabla exportable en formato HTML con la función
kbldel paquetekableExtra. Podemos incluir un título para la tabla, modificar el estilo e incluso los títulos de las columnas.
pairs_list <- list(c(1,2), c(1,3), c(1,4), c(2,3), c(2,4), c(3,4))
for(i in 1:length(pairs_list)){
areas <- lecturaDIF%>%
filter(area %in% c(pairs_list[[i]][1],pairs_list[[i]][2]))
reactivos <- areas%>%
select(!area)
MH<-difMH(reactivos, areas$area, focal.name= pairs_list[[i]][2])
coef <- round(-2.35 * log(MH$alphaMH), 4)
DIF <- as.vector(symnum(coef, c(-Inf,-1.5,-1, 1, 1.5, Inf), symbols = c("--","-","", "+", "++")))
tabladif<-paste(coef, DIF, sep = " ")%>%as.data.frame()%>%
set_names(noquote(paste0("Area",pairs_list[[i]][1]," VS Area", pairs_list[[i]][2])))
if(exists("TablaDIF_CL_MH") == T){TablaDIF_CL_MH <- bind_cols(TablaDIF_CL_MH, tabladif)}
if(exists("TablaDIF_CL_MH") == F){TablaDIF_CL_MH <- bind_cols(reactivos = names(reactivos_lectura), tabladif)}
print(names(TablaDIF_CL_MH))
}
TablaDIF_CL_MH%>%
kbl(caption = "Presencia de DIF Área vs Área en Examen de Lectura con Método Mantel-Haenszel")%>%
kable_classic(full_width = F, html_font = "Cambria")%>%
add_header_above(c(" " = 1, "FMI y CBQS" = 1, "FMI y CS" = 1, "FMI y HA" = 1,
"CBQS y CS" = 1, "CBQS y HA" = 1, "CS y HA" = 1))Método de Regresión Logística
Para el método de Regresión logística, se utiliza un procedimiento similar al anterior, incluyendo un bucle que llama la función difLogistic del mismo paquete difR. La principal diferencia con el código anterior es que, en lugar de calcular manualmente a partir del valor de Alfa de Mantel-Haenszel, en este caso tomamos directamente el valor p para determinar el nivel de DIF, complementando esta prueba de significancia con el valor de delta que la misma prueba arroja. Otro aspecto distinto al utilizar este método de regresión logística es que tenemos la posibilidad de buscar DIF de tipo uniforme o no uniforme al especificar el parámetro type = "udif" o type = "nudif".
for(i in 1:length(pairs_list)){
areas <- lecturaDIF%>%
filter(area %in% c(pairs_list[[i]][1],pairs_list[[i]][2]))
reactivos <- areas%>%
select(!area)%>%as.data.frame()
LR<-difLogistic(reactivos, areas$area, type = "udif", focal.name= pairs_list[[i]][2])
coef <- round(LR$p.value, 4)
DIF <- as.vector(symnum(coef, abs(c(0,0.001,0.01,0.05,1)),
symbols = c("***","**", "*", "")))
delta <- round(LR$deltaR2,3)
tabladif<- paste(delta, DIF, sep = " ")%>%as.data.frame()%>%
set_names(noquote(paste0("Area",pairs_list[[i]][1]," VS Area", pairs_list[[i]][2])))
if(exists("TablaDIF_CL_LRU") == T){TablaDIF_CL_LRU <- bind_cols(TablaDIF_CL_LRU, tabladif)}
if(exists("TablaDIF_CL_LRU") == F){TablaDIF_CL_LRU <- bind_cols(reactivos = names(reactivos_lectura), tabladif)}
print(names(TablaDIF_CL_LRU))
}
TablaDIF_CL_LRU%>%
kbl(caption = "Análisis DIF con método de Regresión Logística para DIF uniforme área contra área en examen de comprensión lectora")%>%
kable_classic(full_width = F, html_font = "Cambria")%>%
add_header_above(c(" " = 1, "FMI y CBQS" = 1, "FMI y CS" = 1, "FMI y HA" = 1,
"CBQS y CS" = 1, "CBQS y HA" = 1, "CS y HA" = 1))Regresión logística recursiva exploratoria
Además del método mostrado en la sección anterior, también es posible utilizar un método exploratorio recursivo de regresión logística gracias a la función DIFtree() perteneciente al paquete del mismo nombre. Para utilizar esta función, necesitamos realizar algunos pasos previos. Dentro del siguiente bloque de código se ejecutan todas las siguientes acciones:
Se genera una base de datos que únicamente contenga las covariables a utilizar, asegurándonos de que todas sean variables de tipo numérico
Se crea una matriz de datos que incluya únicamente los reactivos a analizar, asegurándonos de no tener valores perdidos.
Se ejecuta la función DIFtree especificando la matriz de reactivos a utilizar, la base de datos con las covariables, el tipo de análisis y si es para detectar DIF uniforme o no uniforme. Los resultados de este análisis se asignan a un nuevo objeto que en este caso nombramos
nudiftree_lecturaSe extraen los resultados del análisis utilizando la función
summary().Finalmente, se reportan los resultados mediante una tabla utilizando la función gt del paquete del mismo nombre.
## Regresión logística recursiva exploratoria
## DIFtree
library(DIFtree)
### Preparacion de los datos
covar_lectura <- lectura%>%
select(area,posgrado, genero, nivel)%>%
bind_cols(reactivos_lectura)%>%
drop_na()%>%
select(area,posgrado, genero, nivel)%>%
mutate_all(as.numeric)%>%
as.data.frame()
diftree_data <- lectura%>%
select(area,posgrado, genero, nivel)%>%
bind_cols(reactivos_lectura)%>%
drop_na()%>%
select(LR1:LR11)%>%
as.matrix()
### Ejecución de análisis
nudiftree_lectura<- DIFtree(Y = diftree_data, X = covar_lectura,
model = "Logistic", type = "udif")
tabla_logtree <- summary(nudiftree_lectura)
tabla_logtree[[2]]%>%
dplyr::rename(Tipo = type, Particiones = nosplits)%>%
apa("Tabla 9. Análisis DIF exploratorio del examen de lectura y gramática con
método recursivo de regresión logística")Los números de tabla que aparecen en los títulos del código no siguen la numeración de la tesis, donde este resultado es la Tabla 6 y la Tabla 9 corresponde a la comparación de dos grupos con los cuatro métodos tradicionales. Con type = "udif", como en el bloque, se reproduce la Tabla 6; con type = "dif", que admite también DIF no uniforme, el 6 y el 10 salen como no uniformes y el 5 deja de tener DIF.
Métodos DIF paramétricos
Exploración de los datos con Mapa de Wright
Dentro del marco de la TRI, una visualización útil es el llamado Mapa de Wright, que permite visualizar bajo una misma escala tanto a los reactivos como a los sustentantes. Para elaborarlo, el paquete WrightMap es una herramienta muy útil, aunque requiere de algunos pasos adicionales que involucran otro tipo de paquetes como el paquete mirt para generar el modelo TRI o algunas funciones adicionales básicas de R para la generación del gráfico. Para elaborarlo, necesitamos realizar los siguientes pasos:
Generar el modelo de Rasch mediante la función
mirt()proveniente del paquete con el mismo nombre. Para generar el modelo, necesitamos proveer los siguientes parámetros dentro de la función:- La base de datos que contiene únicamente los reactivos
- La cantidad de dimensiones que incluye el modelo, en este caso, al tratarse de un modelo simple de Rasch, simplemente indicaremos que se trata de un modelo unidimensional escribiendo
model = 1. - El tipo de modelo para los reactivos, que en este caso se trata de un modelo de Rasch
A partir de este modelo, es posible extraer los niveles de habilidad de cada uno de los sustentantes utilizando la función
fscores()con el nombre del modelo como el único parámetro dentro de ella. Dado que la intención de este gráfico en particular es contrastar las 4 áreas a evaluar, necesitaremos combinar esta información con nuestra base de datos que incluye a qué área pertenecía cada sustentante. La función fscores devuelve los niveles de habilidad en el orden original de la base de datos, por lo que podemos simplemente combinar las columnas con la funcióncbind.Para crear el mapa de Wright, necesitaremos crear cuatro gráficos distintos para los sustentantes, uno para cada área. Por ello, necesitaremos crear cuatro bases de datos distintas con los niveles de habilidad; para ello, simplemente podemos usar la función
filter()del paquetedplyr, especificando en cada uno que queremos conservar únicamente los casos correspondientes a una sola de las áreas.Una vez que tenemos los niveles de habilidad de los sustentantes por área, solamente nos hace falta obtener los niveles de dificultad de los reactivos; para ello, la función
coef()nos permite extraer los distintos parámetros de los reactivos. Para hacer el proceso más sencillo, se sugiere incluir los siguientes parámetros dentro de la función:IRTpars=TRUE, simplify=TRUE.Esta función devuelve una lista con distintos objetos. Para extraer los niveles de dificultad por reactivo solamente, se extrae el primer objeto de la lista y la segunda columna de la siguiente forma:
coef.Rasch_CL[[1]][,2].
## Mapa de Wright con modelo Rasch
library(ShinyItemAnalysis)
library(WrightMap)
library(RColorBrewer)
modeloRasch_CL <- mirt(data=reactivos_lectura,model = 1, itemtype="Rasch",
SE=TRUE, verbose=FALSE)
thetaRaschScores <-fscores(modeloRasch_CL)%>%
cbind(area = lectura$area)%>%
as_tibble()
thetaRaschCL1 <- thetaRaschScores%>%
filter(area == 1)%>%
select(F1)
thetaRaschCL2 <- thetaRaschScores%>%
filter(area == 2)%>%
select(F1)
thetaRaschCL3 <- thetaRaschScores%>%
filter(area == 3)%>%
select(F1)
thetaRaschCL4 <- thetaRaschScores%>%
filter(area == 4)%>%
select(F1)
coef.Rasch_CL <- coef(modeloRasch_CL, IRTpars=TRUE, simplify=TRUE)
b_RaschCL <- coef.Rasch_CL[[1]][,2]
item_color_palette <- matrix(rep(c("#44BEFE","#098BD1","#0D547A","#092A3C")),
10, byrow = TRUE, ncol = 4)
split.screen(figs = matrix(c( 0, .10,0,1,
.10, .20,0,1,
.20, .30,0,1,
.30, .40,0,1,
.40, 1, 0,1), ncol = 4, byrow = TRUE))
personHist(thetaRaschCL1,yRange = c(-1.2,1.5), dim.lab.cex = 1,
dim.names = "CFMI", dim.color = "#44BEFE",
show.axis.logits = FALSE, axis.persons = "Sustentantes", breaks = 10)
screen(2)
personHist(thetaRaschCL2,yRange = c(-1.2,1.5), dim.lab.cex = 1,
dim.names = "CBQS", dim.color = "#098BD1",
show.axis.logits = FALSE, axis.persons = NULL, breaks = 10)
screen(3)
personHist(thetaRaschCL3,yRange = c(-1.2,1.5), dim.lab.cex = 1,
dim.names = "CS", dim.color = "#0D547A",
show.axis.logits = FALSE, axis.persons = NULL, breaks = 10)
screen(4)
personHist(thetaRaschCL4, yRange = c(-1.2,1.5), dim.lab.cex = 1,
dim.names = "HA", dim.color = "#092A3C",
show.axis.logits = FALSE,axis.persons = NULL, breaks = 10)
screen(5)
itemModern(b_RaschCL, yRange = c(-1.2,1.5), thr.sym.cex = 2, thr.sym.col.bg = "#092A3C")
mtext("Figura 3. Mapa de Wright del examen de lectura y gramática con distribución por área", side = 3, font = 2, line = 1)
close.screen(all.screens = TRUE)Análisis Raschtree para detección exploratoria de DIF
Los métodos anteriores requieren decidir de antemano qué variable define los grupos. El raschtree invierte el planteamiento: recibe un conjunto de covariables y busca por sí mismo qué particiones de la muestra producen diferencias en los parámetros de los reactivos. En lugar de responder “¿hay DIF por área?”, responde “¿por qué variable, y en qué punto de corte, se parten los datos de forma que los reactivos dejen de comportarse igual?”.
La preparación tiene una particularidad que conviene notar: la función espera que la matriz de respuestas sea una sola columna dentro del data frame, no once columnas sueltas. Por eso el primer paso guarda los reactivos como una matriz en la columna resp, y el segundo elimina las columnas individuales para que no aparezcan también como covariables:
## Figura 5 - Análisis Raschtree
library(psychotree)
lectura_dicotomicos$resp <- as.matrix(lectura_dicotomicos[, 6:16])
## Eliminar variables no requeridas
lecturaDIF1 <- lectura_dicotomicos[, -(6:16)]
## Creación de modelo psychotree lectura
raschtreeCL1 <- psychotree::raschtree(resp ~ area + genero + nivel, data = lecturaDIF1)
## Gráfica del modelo
plot(raschtreeCL1, title = "Figura 5")Es la fórmula que produjo la Figura 5 de la tesis. La Figura 6 añade la edad (resp ~ area + edad + genero + nivel), y agregar el posgrado no cambia el árbol.
Análisis DIF con método de diferencia de Logits con un modelo Rasch
Este método ajusta un modelo de Rasch y luego estima los parámetros de dificultad por separado para cada grupo y los compara con la prueba de Wald. El DIF se expresa como la diferencia entre ambos, en logits, lo que tiene la ventaja de ser directamente interpretable: un valor de 0.5 significa que el reactivo es medio logit más difícil para un grupo que para el otro, controlando el nivel de habilidad. En la función de abajo, la diferencia es la dificultad del área de código menor menos la del área de código mayor, de modo que un valor negativo indica que el reactivo es más difícil para la segunda.
La contrapartida es que todo el procedimiento descansa sobre el supuesto de Rasch —discriminación igual en todos los reactivos—, y si ese modelo no ajusta a los datos, la comparación se vuelve poco informativa. Conviene por tanto revisar el ajuste del modelo antes de leer estos resultados; el porqué está desarrollado en el artículo de comparación.
Nota sobre esta función
DIFRasch()espera quebasecontenga las columnas administrativas deareaaplantel_UNAMseguidas de los reactivos ya calificados como acierto o error, porque la selecciónselect(!area:plantel_UNAM)descarta las primeras por posición. La baselecturade esta guía guarda la opción elegida, no el acierto, así que para usar la función hay que construir antes una base equivalente con los reactivos calificados.
library(eRm)
DIFRasch <- function(base, referencia, focal){
areas <- base%>%
filter(area %in% c(referencia,focal))
reactivos <- areas%>%
select(!area:plantel_UNAM)
modeloR<-RM(reactivos)
## Waldtest() ordena los grupos por su código: coef es la dificultad del área
## de código menor menos la del mayor, sin importar cuál se pase como focal
subgroup_diffs <- Waldtest(modeloR, splitcr = areas$area)
subgroup_1_diffs <- subgroup_diffs$betapar1
subgroup_2_diffs <- subgroup_diffs$betapar2
coef <- round((subgroup_1_diffs*-1)-(subgroup_2_diffs*-1),3)
DIF <- symnum(coef, c(-Inf,-1,-.5, .5, 1, Inf), symbols = c("--","-","", "+", "++"))
tabla_dif<-cbind(coef, DIF)
}Análisis DIF con método de Ji cuadrada de Lord
El método de Ji cuadrada de Lord compara directamente los parámetros estimados de cada reactivo entre el grupo de referencia y el grupo focal, bajo un modelo de TRI que se especifica explícitamente. Esa especificación es lo que lo distingue de los métodos anteriores: al pasar model = "2PL" se pide que la comparación se haga sobre un modelo de dos parámetros, en lugar de heredar los supuestos del modelo de Rasch.
Como el procedimiento se repite para cada par de áreas, conviene encapsularlo en una función en lugar de un bucle. La función recibe la base, el grupo de referencia y el grupo focal, y devuelve el estadístico de cada reactivo acompañado de su nivel de significancia:
## DIF X2 de Lord
## Función personalizada para análisis DIF con X2 de Lord
DIF_LORD <- function(base, referencia, focal){
areas <- base%>%
filter(area %in% c(referencia,focal))
reactivos <- areas%>%
select(!area)
Lord<-difLord(reactivos, areas$area, focal.name= focal, model = "2PL")
coef <- round(Lord$p.value, 4)
DIF <- as.vector(symnum(coef, abs(c(0,0.001,0.01,0.05,1)),
symbols = c("***","**", "*", "")))
chi <- round(Lord$LordChi,3)
tabladif<- paste(chi, DIF, sep = " ")
}Análisis DIF con áreas combinadas
Dividir la muestra en cuatro áreas deja grupos pequeños, y varios de los métodos anteriores pierden potencia —o directamente no convergen— con pocos casos por grupo. Una alternativa es colapsar las cuatro áreas en dos agrupaciones defendibles conceptualmente: por un lado las áreas de ciencias exactas y de la salud, por el otro las de ciencias sociales y humanidades.
Esto no sustituye al análisis por pares, sino que lo complementa: da una partición con suficientes casos en cada lado como para que los métodos basados en TRI trabajen con holgura, a costa de perder la resolución entre áreas específicas.
En la tesis, las funciones de las secciones anteriores se llamaron con (lectura_2grps, 2, 1), es decir, con CS y HA como referencia y CFMI y CBQS como grupo focal. La Tabla 9 de la tesis se calculó además con los 1,218 casos, sin el drop_na() del bloque; con él quedan 1,201 y algunos valores cambian un poco (el MH D-DIF del reactivo 10, por ejemplo, pasa de 1.000 a 0.990). Para usar DIFRasch() con esta base, que solo tiene area y los reactivos, hay que cambiar su selección por select(LR1:LR11).
## DIF con 2 grupos. Comparación de métodos
lectura_2grps <- lecturaDIF%>%
mutate(area = case_when(area %in% 1:2 ~ 1, area %in% 3:4 ~ 2))%>%
mutate(area = as.factor(area))%>%
select(area,LR1:LR11)%>%
drop_na()Análisis DIF con metodologías robustas mediante TRI
Los métodos de esta última sección son los más exigentes, y también los que hacen explícito algo que los anteriores dejan implícito: antes de comparar grupos hay que decidir qué modelo representa los datos. El bloque siguiente hace las dos cosas en orden.
Primero, la selección del modelo. Se estiman los modelos de Rasch, 2PL y 3PL, se comparan sus índices de bondad de ajuste mediante el estadístico M2, y se contrasta además el modelo unidimensional elegido contra uno exploratorio de dos dimensiones mediante una prueba de razón de verosimilitud. Sobre el modelo resultante, que en la tesis fue el 2PL unidimensional, se revisa el supuesto de independencia local con los residuales Q3; en la tesis, ningún par de reactivos pasó de .2 en valor absoluto. El Anexo de la tesis calcula el Q3 sobre el modelo de dos dimensiones, pero la Figura 7 corresponde al 2PL unidimensional, que es el que usa este bloque. Además, el modelo de dos dimensiones no convergió, así que la comparación de la Tabla 11 conviene leerla con reserva; antes de leer esa prueba con tus propios datos, comprueba que CL_mirt2 haya convergido (extract.mirt(CL_mirt2, "converged")).
Después, el análisis DIF propiamente dicho. Se estima un modelo de dos parámetros por grupo con multipleGroup() y se contrasta la invarianza de los parámetros a1 y d con la función DIF(). El procedimiento se ejecuta dos veces: la primera de forma exploratoria, para identificar los reactivos que se comportan de manera más estable y que pueden servir como anclas; la segunda fijando esos reactivos ancla mediante el argumento invariance, de modo que la comparación entre grupos se haga sobre una escala común. Sin ese segundo paso, los parámetros de cada grupo están en escalas que no son directamente comparables y el resultado es difícil de interpretar. En la tesis, el primer paso con las cuatro áreas dejó como único reactivo libre de DIF al 2, que es el que fija el código (LR2); en la comparación de CFMI y CBQS contra CS y HA, las anclas fueron el 1, el 2 y el 8.
## Análisis DIF TRI robustos
## Selección de modelo TRI
## Generación de cada modelo
CL_irt1<-mirt(reactivos_lectura, model = 1, itemtype = "Rasch")
CL_irt2<-mirt(reactivos_lectura, model = 1, itemtype = "2PL")
CL_irt3<-mirt(reactivos_lectura, model = 1, itemtype = "3PL")
CL_mirt2<-mirt(reactivos_lectura, model = 2, itemtype = "2PL") # Modelo bidimensional
### Generación de índices de bondad de ajuste
fit_CL_irt1 <- M2(CL_irt1, na.rm = TRUE)
fit_CL_irt2 <- M2(CL_irt2, na.rm = TRUE)
fit_CL_irt3 <- M2(CL_irt3, na.rm = TRUE)
nombres_modelos1 <- c("Modelo Rasch", "Modelo 2PL", "Modelo 3PL")
## Tabla final
rbind(fit_CL_irt1, fit_CL_irt2, fit_CL_irt3)%>%
select(M2:RMSEA, CFI)%>%
cbind(nombres_modelos1, .)%>%
gt(rowname_col = "nombres_modelos1")%>%
tab_header(title = "Tabla 3. Comparación de modelos TRI unidimensionales en examen de lectura y gramática")%>%
tab_stubhead(label = "Modelos")%>%
fmt_number(columns = c(2,4:6),
decimals = 3)
## Análisis de Razón de Verosimilitud entre modelo unidimensional y bidimensional
anova_irt1_vs_mirt2 <-anova(CL_irt2, CL_mirt2)
## Generación de la tabla
Modelos <- c("Modelo 1", "Modelo 2")
anova_irt1_vs_mirt2%>%
select(AIC, BIC, logLik, X2, df, p)%>%
cbind(Modelos, .)%>%
gt()%>%
tab_header(title = "Tabla 4. Comparativa de Modelo unidimensional y multidimensional")%>% fmt_number(columns = 2:5,
decimals = 2)%>%
tab_footnote(footnote = "Modelo unidimensional",
locations = cells_body(columns = Modelos, rows = 1))%>%
tab_footnote(footnote = "Modelo exploratorio de dos dimensiones",
locations = cells_body(columns = Modelos, rows = 2))
## Evaluación de supuesto de independencia local con modelo elegido
residuals(CL_irt2, type = "LDG2")
residuals(CL_irt2, type = "Q3")%>%
corrplot(method = "number", type="lower", col=brewer.pal(n=11, name="RdBu"),tl.srt=90)
## Generación de modelo de dos parámetros específicos para cada grupo usando multipleGroup del paquete mirt
modelo2PL_CL_A <- multipleGroup(data=reactivos_lectura,model = 1, group = factor(lectura$area),
itemtype="2PL", SE=TRUE)
#Análisis DIF a partir del modelo de dos parámetros anterior
dif_CL1 <-DIF(modelo2PL_CL_A, which.par = c("a1", "d"))
dif_CL1%>%arrange(X2)
## A partir del análisis anterior, se identifican reactivos ancla y se vuelve a ejecutar el análisis fijando los parámetros en dichos reactivos
modelo2PL_CL_ANCH <- multipleGroup(data=reactivos_lectura,model = 1, group = factor(lectura$area),
itemtype="2PL", SE=TRUE, invariance = c(c("LR2"),
'free_means', 'free_var'))
#Análisis DIF ya con los reactivos ancla
dif_CL2<-DIF(modelo2PL_CL_ANCH, c("a1", "d"), items2test = c(1,3:11), maxiter = 1000)
nombres_reactivos2 <- rownames(dif_CL2)
## generación de la tabla final
dif_CL2%>%
cbind(nombres_reactivos2,.)%>%
rename(Reactivos =nombres_reactivos2)%>%
gt()%>%
tab_header(title = "Análisis DIF con método de Razón de Verosimilitud en examen de lectura y gramática")%>%
fmt_number(columns = c(3:7,9),
decimals = 3)Comprobación con los datos sintéticos
Como el DIF de esta guía se sembró a propósito en los reactivos 4, 6 y 10, se puede verificar qué tanto lo recupera cada método. El bloque siguiente lo hace con Mantel-Haenszel y con la regresión logística.
## Recuperación del DIF sembrado, comparando areas 1 y 2 (sin DIF)
## contra areas 3 y 4 (con DIF)
grupos_2 <- ifelse(area %in% c(1, 2), 1, 2)
MH_check <- difMH(reactivos_lectura, grupos_2, focal.name = 2)
LR_check <- difLogistic(as.data.frame(reactivos_lectura), grupos_2,
type = "udif", focal.name = 2)
data.frame(
Reactivo = items,
Sembrado = ifelse(seq_len(J) %in% items_dif, "si", "no"),
MH_p = round(MH_check$p.value, 4),
RegLog_p = round(LR_check$p.value, 4)
) %>%
kbl(caption = "Recuperación del DIF sembrado con datos sintéticos") %>%
kable_classic(full_width = FALSE, html_font = "Cambria")Para leer completo
Esta guía cubre el cómo. La discusión de qué significan los resultados, por qué los métodos divergen y qué se puede concluir de ello está repartida entre dos lugares:
- El artículo Ocho análisis, ocho respuestas distintas, en este mismo sitio, para la comparación entre métodos y el papel de la selección del modelo.
- La tesis completa, para el estudio íntegro, incluido el examen de redacción y gramática que aquí no se aborda:
Díaz García, D. E. (2024). El papel del funcionamiento diferencial del reactivo (DIF) en la validez de exámenes de alto impacto [Tesis de maestría, Universidad Nacional Autónoma de México]. Programa de Maestría y Doctorado en Psicología, Residencia en Evaluación Educativa, Facultad de Psicología. Consultar en TESIUNAM
Nota sobre la elaboración
Los análisis y el código de esta guía provienen de mi tesis de maestría y son de mi autoría. La redacción de esta versión y el bloque de generación de datos sintéticos se elaboraron con asistencia de IA, y fueron revisados y verificados por mí. La responsabilidad por el contenido es enteramente mía.
Referencias
- Bulut, O. y Suh, Y. (2017). Detecting multidimensional differential item functioning with the multiple indicators multiple causes model, the item response theory likelihood ratio test, and logistic regression. Frontiers in Education, 2, 51. https://doi.org/10.3389/feduc.2017.00051
- Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R environment. Journal of Statistical Software, 48(6), 1–29. https://doi.org/10.18637/jss.v048.i06
- Magis, D., Béland, S., Tuerlinckx, F. y De Boeck, P. (2010). A general framework and an R package for the detection of dichotomous differential item functioning. Behavior Research Methods, 42(3), 847–862.
- Mair, P. y Hatzinger, R. (2007). Extended Rasch modeling: The eRm package for the application of IRT models in R. Journal of Statistical Software, 20(9), 1–20. https://doi.org/10.18637/jss.v020.i09
- Strobl, C., Kopf, J. y Zeileis, A. (2015). Rasch trees: A new method for detecting differential item functioning in the Rasch model. Psychometrika, 80(2), 289–316. https://doi.org/10.1007/s11336-013-9388-3
- Tutz, G. y Berger, M. (2016). Item-focussed trees for the identification of items in differential item functioning. Psychometrika, 81(3), 727–750. https://doi.org/10.1007/s11336-015-9488-3
- Zwick, R. (2012). A review of ETS differential item functioning assessment procedures. Educational Testing Service. https://doi.org/10.1002/j.2333-8504.2012.tb02290.x