Analizar el mercado laboral y los factores que determinan los ingresos salariales es clave para comprender el grado de equidad socioeconómica y el desarrollo de un país. En Colombia, el principal instrumento para evaluar estas dinámicas es la Gran Encuesta Integrada de Hogares (GEIH), que realiza el Departamento Administrativo Nacional de Estadística (DANE). Esta encuesta continua recoge, a través de sus microdatos, información sociodemográfica, educativa y laboral de los hogares del país, lo que permite estimar indicadores como la ocupación, el desempleo, la informalidad y los ingresos, y descomponer la realidad laboral individual para entender cómo interactúan las características personales y las condiciones de contratación en la generación de ingresos (DANE, 2024).
El ingreso laboral es la principal fuente de recursos de los hogares y suele asociarse con factores sociodemográficos, como la edad, el sexo, la educación y la zona de residencia, y con factores laborales, como las horas trabajadas, la antigüedad y el tipo de empleo, que pueden contribuir a las brechas existentes en el mercado laboral. En los datos analizados, el ingreso se concentra en torno al salario mínimo: la mediana del ingreso laboral ($1.160.000) es inferior al Salario Mínimo Mensual Legal Vigente (SMMLV) de 2024 y solo una minoría de los ocupados (35,5 %) lo supera. Con base en lo anterior, el presente trabajo se centra en el procesamiento técnico y el análisis predictivo de los microdatos de la GEIH correspondientes a enero de 2024. Tomando este periodo como referencia, aborda un problema fundamental y plantea la siguiente pregunta de investigación:
¿En qué medida las condiciones laborales y las características sociodemográficas permiten clasificar con precisión a la población según su nivel de ingreso, de acuerdo con los datos de la GEIH de enero de 2024?
Para responderla se propone un enfoque cuantitativo de clasificación binaria. La variable dependiente es el ingreso laboral mensual de los individuos, categorizado con el SMMLV de 2024 ($1.300.000 COP) como umbral: se considera ingreso «Alto» si lo supera y «Bajo» en otro caso. Como variables explicativas se emplean nueve características: sexo, edad, edad al cuadrado, zona de residencia, nivel educativo, horas trabajadas, antigüedad en el empleo, tipo de empleado y estado civil. Se evalúa y compara el desempeño de dos algoritmos: la Regresión Logística (Logit) y los K-Vecinos Más Cercanos (KNN), mediante métricas de clasificación (exactitud, sensibilidad, especificidad, precisión, kappa y AUC) y curvas ROC. El estudio busca aportar un diagnóstico riguroso sobre las brechas de ingreso a comienzos de 2024 y determinar qué metodología ofrece mayor capacidad predictiva con estos microdatos.
El contenido del trabajo inicia con la metodología donde se definen la variable dependiente y las variables independientes, además de explicar la construcción y limpieza de la base a emplear; los resultados descriptivos caracterizan el ingreso y su relación con cada variable explicativa; los resultados del modelo presentan el ajuste de KNN y de Logit; la comparación de modelos contrasta su desempeño; y las conclusiones responden la pregunta de investigación y señalan las limitaciones del análisis.
La variable dependiente es el ingreso laboral mensual de los
trabajadores ocupados (inglabo en la base de datos del
estudio, con código INGLABO del DANE), expresado en pesos
colombianos. Para convertir un problema de ingreso continuo en uno de
clasificación y prevenir futuros errores en los modelos, el ingreso se
transforma en una variable binaria Y que compara cada
ingreso con el Salario Mínimo Mensual Legal Vigente,
que es la referencia legal y de política pública que permite evaluar si
un trabajador alcanza el nivel de remuneración laboral suficiente.
Para el periodo de enero de 2024, el SMMLV oficial fijado por el Gobierno Nacional es de $1.300.000 COP (Decreto 2292 de 2023). Se fija la categoría “Alto” como clase positiva, de modo que la Sensibilidad mide la capacidad de los modelos para identificar correctamente a los trabajadores que superan el salario mínimo.
Tabla 1. Definición de la variable dependiente
| Variable | Código DANE | Tipo | Categoría / unidad |
|---|---|---|---|
Ingreso laboral inglabo |
INGLABO |
Numérica | Pesos mensuales |
Clase de ingreso y |
Construida a partir de INGLABO |
Categórica | Alto (1) si supera el SMMLV, Bajo (0) en otro caso |
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Sexo: variable categórica que distingue entre hombre y mujer, su categoría base es Hombre. Se incluye porque permite medir si, a igualdad de condiciones el sexo hace una diferencia en la remuneración laboral, debido a la evidencia de que existen brechas salariales de género persistentes en los mercados laborales, frecuentemente asociadas al cargo laboral, las horas remuneradas y la carga de trabajo no remunerado
Edad: variable numérica que mide los años cumplidos
de la persona, tomada de P6040. Se incluye como
aproximación de la experiencia laboral debido a que a mayor edad, mayor
tiempo acumulado en el mercado y se pueden percibir mayores ingresos. Se
complementa con la edad al cuadrado para capturar la forma de U
invertida del ingreso a lo largo de la vida laboral.
Edad al cuadrado: variable numérica que eleva al
cuadrado la edad en años cumplidos, construida a partir de
P6040 y aparece calculada en la base como
Edad_Cuadrado. Se incorpora porque sirve como una
aproximación de la experiencia laboral acumulada, dado que se asocia que
los trabajadores de mayor edad suelen tener más experiencia acumulada y
un mejor desempeño laboral que los trabajadores jóvenes; además de que
se tiene una relación no lineal, dado que el ingreso crece durante la
vida laboral, se estabiliza y luego cae, así el término cuadrático
permite capturar esa forma de U invertida.
Nivel educativo: variable categórica de cinco
niveles: Primaria, Secundaria, Técnica/Tecnología, Universitaria y
Postgrado, construida a partir de P3042. Se selecciona
porque una mayor formación académica suele asociarse con una alta
especialización y esta a su vez da acceso a ocupaciones mejor
remuneradas. Coincide con estudios sobre el retorno de la educación en
Colombia que demuestran que frente a niveles de educación bajos la
educación superior suele tener una remuneración laboral más alta.
Estado civil: variable categórica de seis grupos:
unión libre menor a 2 años, unión libre de 2 años o más, casado,
separado/divorciado, viudo y soltero, tomada de P6070. Se
incluye porque la situación conyugal puede asociarse con la estabilidad
laboral, la disponibilidad de tiempo y las decisiones de trabajo en el
hogar.
Zona (urbano/rural): variable categórica que
distingue entre cabecera urbana y resto rural, tomada de
CLASE. Se selecciona porque los mercados laborales urbanos
y rurales difieren en oferta de empleo, productividad, acceso a
educación y formalidad, lo que genera brechas de ingreso entre
territorios.
Tipo de empleado: variable categórica de seis
grupos: empleado de empresa particular, empleado del gobierno, empleado
doméstico, trabajador por cuenta propia, patrón o empleador y otros
(trabajador sin pago, jornalero u otros), construida a partir de
P6430. Se incorpora dado que el tipo de vinculación suele
determinar la estabilidad, la protección social y el nivel de
remuneración; en Colombia más de la mitad de la población trabaja en la
informalidad por ello esta variable resume diferencias en el mercado
laboral que la educación no explica por sí sola.
Horas trabajadas a la semana: variable numérica que
representa el tiempo promedio o estipulado que una persona dedica a su
jornada laboral, construida a partir de P6800. Se incluye
porque se asocia que de la cantidad de tiempo trabajado depende la
remuneración laboral recibida, sobre todo para aquellas personas que
trabajan por tiempo o por servicios.
Antigüedad en el trabajo: variable numérica que mide
los meses que la persona lleva en su ocupación actual, tomada de
P6426. Se incorpora porque la permanencia en un mismo
empleo suele reflejar experiencia específica, estabilidad y ascensos que
elevan la remuneración.
Tabla 2. Variables independientes: códigos del DANE, tipo y categorías
| Nombre en la base de datos | Código DANE | Tipo | Categoría / unidad |
|---|---|---|---|
Sexo sexo_f |
P3271 |
Categórica | 1 = Hombre, 2 = Mujer |
Edad edad |
P6040 |
Numérica | Años cumplidos |
Edad al cuadrado edad2 |
P6040 |
Numérica | Años cumplidos elevados al cuadrado |
Zona de residencia zona_f |
CLASE |
Categórica | 1 = Urbano (cabecera), 2 = Rural (centros poblados y rural disperso) |
Nivel educativo educ_f |
P3042 |
Categórica | 1 = Primaria, 2 = Secundaria, 3 = Técnica/Tecnología, 4 = Universitaria, 5 = Posgrado |
Horas trabajadas horas |
P6800 |
Numérica | Horas por semana |
Antigüedad antig |
P6426 |
Numérica | Meses en el trabajo actual |
Tipo de empleado tipo_emp |
P6430 |
Categórica | 1 = Empresa particular, 2 = Gobierno, 3 = Doméstico, 4 = Cuenta propia, 5 = Patrón o empleador, 6–9 = Otros (familiar sin pago, jornalero u otro) |
Estado civil est_civil_f |
P6070 |
Categórica | 1 = Unión libre (<2 años), 2 = Unión libre (≥2 años), 3 = Casado(a), 4 = Separado(a) o divorciado(a), 5 = Viudo(a), 6 = Soltero(a) |
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La base se construyó a partir de la selección de dos archivos de la GEIH, siendo Características generales, seguridad social en salud y educación el primero que contenía las variables sexo, edad, estado civil, zona (urbano/rural) y nivel educativo. Y Ocupados el segundo de donde se seleccionaron las variables ingreso laboral, tipo de empleado, antigüedad y horas semanales.
Posteriormente, ambos archivos fueron integrados por medio de las
variables identificadoras DIRECTORIO,
SECUENCIA_P y ORDEN, permitiendo que las
observaciones correspondientes a una misma persona pudieran ser
relacionadas entre las dos bases de datos. De modo que se obtuvo una
primera base consolidada con 70.648 observaciones que
contenía la información sociodemográfica y laboral necesaria para el
estudio.
Después de la integración, se aplicaron algunos criterios de depuración que se consideraban acorde al objetivo del estudio. Del archivo de ocupados se conservaron únicamente las personas con un tipo de empleo, que reportaron un ingreso laboral y que este fuera mayor a cero, siendo eliminadas 41.833, 1.235 y 218 observaciones respectivamente.
Del archivo Características generales, seguridad social en
salud y educación se seleccionaron las personas que tuvieran
18 años o más y se excluyeron los niveles educativos
Ninguno, Preescolar y
Normalista dado que había muy pocos datos sobre estos,
eliminando 144 y 782 observaciones
correspondientes; además se construyeron Y ingreso y
Nivel educativo, por lo que luego de haber aplicado esta
primera depuración se obtuvieron 26.436 observaciones y
12 variables.
Posteriormente a la base que anteriormente fue depurada y contaba con
26.436 observaciones y 12 variables,
se le realizó una segunda limpieza donde debido a que la columna
original Y_ingreso usaba $1.160.000 COP el
salario mínimo de 2023, a la variable dependiente Y se le aplicó un
filtro: es “Alto” si inglabo > $1.300.000
COP que corresponde salario mínimo de 2024 y “Bajo” en caso
contrario, para que el umbral coincidiera con el año de los datos de la
GEIH de enero de 2024.
Luego de las 12 variables se conservaron solo 10, y se les asignó un
nombre legible a cada una: INGLABO pasó a ser
inglabo, P6040 pasó a ser edad,
P6426 pasó a ser antig, P6800
pasó a ser horas, y así sucesivamente con las demás; luego
se descartaronY_ingreso y Nivel_educativo,
además se aplicaron algunos filtros como que la antigüedad fuera
coherente con la edad y en la variable de horas trabajadas se eliminaron
28 observaciones que mostraban más de 100 horas de
trabajo a la semana las cuales no son humanamente posibles.
por último, las variables numéricas tipo_emp,
sexo_f, zona_f, est_civil_f se
convirtieron en factores con etiquetas de variables categóricas
codificadas con números, así R los trata como categorías y no como
cantidades. Posteriormente se agrupó el nivel educativo
educ_f en cinco niveles (Primaria, Secundaria,
Técnica/Tecnología, Universitaria, Postgrado), dado que había categorías
casi vacías como “Doctorado” que tiene solo 65 casos, lo que puede
volver inestables los modelos.
archivo <- "Base_GEIH_Taller2_limpia1.xlsx"
if (!file.exists(archivo)) {
stop("No encuentro '", archivo, "' en la carpeta: ", getwd(),
"\nArchivos .xlsx que sí hay aquí: ",
paste(list.files(pattern = "\\.xlsx$"), collapse = ", "))
}
raw <- read_excel(archivo)
n_raw <- nrow(raw)SMMLV <- 1300000
datos <- raw %>%
transmute(
inglabo = inglabo,
edad = edad,
edad2 = edad2,
antig = antig,
horas = horas,
educ = educ,
tipo = tipo,
P3271 = P3271,
CLASE = CLASE,
P6070 = P6070
) %>%
filter(
!is.na(inglabo), inglabo > 0, # ingreso laboral positivo
edad >= 15,
educ != 99,
horas <= 100,
antig <= 12 * (edad - 10)
) %>%
mutate(
tipo_emp = factor(
case_when(
tipo == 1 ~ "Empleado empresa particular",
tipo == 2 ~ "Empleado del gobierno",
tipo == 3 ~ "Empleado dom\u00e9stico",
tipo == 4 ~ "Cuenta propia",
tipo == 5 ~ "Patr\u00f3n o empleador",
TRUE ~ "Otros (familiar sin pago / jornalero)"
),
levels = c(
"Empleado empresa particular",
"Empleado del gobierno",
"Empleado dom\u00e9stico",
"Cuenta propia",
"Patr\u00f3n o empleador",
"Otros (familiar sin pago / jornalero)"
),
ordered = FALSE
),
educ_f = factor(
case_when(
educ %in% c(1, 2, 3) ~ "Primaria",
educ %in% c(4, 5, 6, 7) ~ "Secundaria",
educ %in% c(8, 9) ~ "T\u00e9cnica/Tecnolog\u00eda",
educ == 10 ~ "Universitaria",
educ %in% c(11, 12, 13) ~ "Posgrado"
),
levels = c(
"Primaria",
"Secundaria",
"T\u00e9cnica/Tecnolog\u00eda",
"Universitaria",
"Posgrado"
),
ordered = FALSE
),
sexo_f = factor(
if_else(P3271 == 1, "Hombre", "Mujer"),
levels = c("Hombre", "Mujer"),
ordered = FALSE
),
zona_f = factor(
if_else(CLASE == 1, "Urbano", "Rural"),
levels = c("Urbano", "Rural"),
ordered = FALSE
),
est_civil_f = factor(
case_when(
P6070 == 1 ~ "Uni\u00f3n libre (<2 a\u00f1os)",
P6070 == 2 ~ "Uni\u00f3n libre (>=2 a\u00f1os)",
P6070 == 3 ~ "Casado(a)",
P6070 == 4 ~ "Separado(a) / Divorciado(a)",
P6070 == 5 ~ "Viudo(a)",
P6070 == 6 ~ "Soltero(a)"
),
levels = c(
"Uni\u00f3n libre (<2 a\u00f1os)",
"Uni\u00f3n libre (>=2 a\u00f1os)",
"Casado(a)",
"Separado(a) / Divorciado(a)",
"Viudo(a)",
"Soltero(a)"
),
ordered = FALSE
),
y = factor(
if_else(inglabo > SMMLV, "Alto", "Bajo"),
levels = c("Bajo", "Alto")
)
) %>%
# códigos fuera de rango producirían NA en los factores: se descartan aquí
tidyr::drop_na(educ_f, est_civil_f, sexo_f, zona_f, tipo_emp, edad, edad2, antig, horas)
n_final <- nrow(datos)
prev <- mean(datos$y == "Alto")Los microdatos de la GEIH se publican periódicamente en el catálogo de microdatos del DANE y se organizan por módulos temáticos (características generales, ocupados, entre otros) con una fila por persona. Este estudio usa el mes de enero de 2024 y reúne las variables de los archivos necesarios para describir a los trabajadores ocupados y su ingreso laboral.
La base final cuenta con 26.408 observaciones y 10 variables de las cuales 8 son variables predictoras y 2 en donde una sirve para construir la variable dependiente; cada una de las observaciones corresponden a trabajadores ocupados con ingreso laboral positivo. Los datos provienen de la GEIH de enero de 2024 publicada por el DANE (microdatos.dane.gov.co).
Las columnas reúnen la variable dependiente (el ingreso laboral y su
clasificación en «Alto» o «Bajo») y las variables independientes
explicativas descritas en la sección anterior. Por razones de espacio,
la tabla no muestra edad2, que se obtiene elevando la edad
al cuadrado. Los códigos originales del DANE se reemplazaron por los
nombres de sus categorías (por ejemplo, P3271 = 1 aparece
como “Hombre”).
La tabla permite filtrar por columna, cambiar el número de filas por página y navegar entre páginas.
Tabla 3. Base de datos depurada utilizada en el estudio
reactable(
tabla_base,
filterable = TRUE,
striped = TRUE,
highlight = TRUE,
defaultPageSize = 15,
showPageSizeOptions = TRUE,
pageSizeOptions = c(10, 15, 25, 50, 100),
paginationType = "numbers",
defaultColDef = colDef(align = "center", minWidth = 150),
columns = list(
`Ingreso laboral` = colDef(
format = colFormat(prefix = "$ ", separators = TRUE, digits = 0, locales = "es-CO"),
minWidth = 190),
`Nivel educativo` = colDef(minWidth = 210),
`Tipo de empleo` = colDef(minWidth = 290),
`Antigüedad (meses)` = colDef(minWidth = 190),
`Estado civil` = colDef(minWidth = 260)
),
theme = reactableTheme(
color = "#2c2a4a",
backgroundColor = "#ffffff",
borderColor = "#e6dafa",
stripedColor = "#faf6ff",
highlightColor = "#f1e8ff",
cellPadding = "10px 12px",
style = list(fontFamily = "Spectral, Georgia, serif", fontSize = "var(--fs)"),
headerStyle = list(background = "#4f518c", color = "#ffffff", borderColor = "#4f518c"),
filterInputStyle = list(background = "#ffffff", borderColor = "#dabfff"),
inputStyle = list(borderColor = "#dabfff"),
paginationStyle = list(borderColor = "#e6dafa"),
pageButtonHoverStyle = list(backgroundColor = "#dabfff"),
pageButtonActiveStyle = list(backgroundColor = "#907ad6", color = "#ffffff"),
pageButtonCurrentStyle = list(backgroundColor = "#2c2a4a", color = "#7fdeff")
)
)Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
El ingreso laboral mensual de los 26.408 trabajadores de la base tiene un promedio de $1.643.080 y una mediana de $1.160.000. Que la media supere a la mediana en un 41,6 % es señal de una distribución asimétrica hacia la derecha, jalonada por un grupo reducido de ingresos muy altos: el máximo es de $102.000.000. Además, la mediana es inferior al SMMLV de 2024, lo que significa que más de la mitad de los ocupados gana menos de $1.300.000. La desviación estándar ($2.421.608) equivale a 1,5 veces la media, lo que refleja una dispersión muy alta. Como referencia adicional, el percentil 25 del ingreso es de $750.000, el percentil 90 de $3.000.000 y el percentil 99 de $10.000.000 (cálculos propios). El valor mínimo es de $2.023, muy por debajo del salario mínimo, lo que puede reflejar actividades ocasionales o de muy pocas horas.
Tabla 4. Estadísticas descriptivas del ingreso laboral mensual (COP)
tabla_est_ingreso <- data.frame(
"N" = fmt_miles(nrow(datos)),
"Ingreso promedio" = paste0("$ ", fmt_miles(mean(datos$inglabo))),
"Mediana" = paste0("$ ", fmt_miles(median(datos$inglabo))),
"Desviación estándar" = paste0("$ ", fmt_miles(sd(datos$inglabo))),
"Mínimo" = paste0("$ ", fmt_miles(min(datos$inglabo))),
"Máximo" = paste0("$ ", fmt_miles(max(datos$inglabo))),
check.names = FALSE
)
DT::datatable(
tabla_est_ingreso,
rownames = FALSE,
options = list(
dom = "t",
paging = FALSE,
searching = FALSE,
ordering = FALSE,
autoWidth = TRUE
),
class = "stripe hover compact",
width = "100%"
)Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Al clasificar el ingreso con el SMMLV de 2024 ($1.300.000), 17.029 trabajadores (64,5 %) quedan en la categoría Bajo y 9.379 (35,5 %) en la categoría Alto; es decir, cerca de dos de cada tres ocupados no supera un salario mínimo. Dentro de la categoría Bajo, 1.672 trabajadores (6,3 % del total) ganan exactamente $1.300.000 y se clasifican como Bajo porque la regla exige superar el SMMLV; los 15.357 restantes (58,2 %) ganan menos. La clase positiva del estudio (Alto) es la minoritaria, con alrededor de 1,8 trabajadores Bajo por cada Alto. Este desbalance moderado implica que un clasificador que predijera siempre «Bajo» acertaría el 64,5 % de las veces, por lo que la exactitud debe leerse junto con la sensibilidad, la especificidad y el kappa.
Tabla 5. Distribución de los trabajadores según nivel de ingreso laboral
tabla_y <- datos %>%
count(y, name = "Trabajadores") %>%
mutate(
Porcentaje = 100 * Trabajadores / sum(Trabajadores)
)
tabla_y_html <- data.frame(
"Nivel de ingreso" = as.character(tabla_y$y),
"Trabajadores" = formatC(
tabla_y$Trabajadores,
format = "d",
big.mark = "."
),
"Porcentaje" = paste0(
formatC(
tabla_y$Porcentaje,
format = "f",
digits = 1,
decimal.mark = ","
),
"%"
),
check.names = FALSE
)
DT::datatable(
tabla_y_html,
rownames = FALSE,
options = list(
dom = "t",
paging = FALSE,
searching = FALSE,
ordering = FALSE,
autoWidth = TRUE
),
class = "stripe hover compact",
width = "100%"
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
El Gráfico 1 muestra la misma información de forma visual: la barra del ingreso Bajo alcanza el 64,5 % y la del ingreso Alto el 35,5 %, una diferencia de 29 puntos porcentuales a favor de quienes ganan hasta el salario mínimo.
Gráfico 1. Proporción de trabajadores según nivel de ingreso laboral
datos_y <- datos %>%
count(y, name = "Trabajadores") %>%
mutate(
Porcentaje = 100 * Trabajadores / sum(Trabajadores),
Etiqueta = paste0(
formatC(
Porcentaje,
format = "f",
digits = 1,
decimal.mark = ","
), "%"
),
Color = if_else(
y == "Bajo", col_bajo, col_alto
),
Detalle = paste0(
"<b>Nivel de ingreso: ", y, "</b>",
"<br>Trabajadores: ",
scales::number(Trabajadores, big.mark = "."),
"<br>Participación: ", Etiqueta,
"<extra></extra>"
)
)
grafico_y <- plotly::plot_ly(
data = datos_y,
x = ~y,
y = ~Porcentaje,
type = "bar",
marker = list(
color = datos_y$Color,
line = list(
color = "white",
width = 1.5
)
),
text = ~Etiqueta,
textposition = "outside",
textfont = list(
size = 17,
color = "#30294F"
),
cliponaxis = FALSE,
hovertemplate = ~Detalle,
showlegend = FALSE
) %>%
plotly::layout(
xaxis = list(
title = "",
tickfont = list(size = 14),
showline = FALSE
),
yaxis = list(
title = "Porcentaje de trabajadores",
ticksuffix = "%",
range = c(0, 77),
gridcolor = "#ECE9F3",
zeroline = FALSE
),
bargap = 0.55,
plot_bgcolor = "white",
paper_bgcolor = "white",
margin = list(
l = 75,
r = 35,
t = 30,
b = 55
),
hoverlabel = list(
bgcolor = "#FFFFFF",
font = list(
size = 13,
color = "#30294F"
)
)
) %>%
plotly::config(
displaylogo = FALSE,
responsive = TRUE
)
grafico_y <- grafico_y %>%
plotly::layout(
xaxis = list(
title = "",
tickmode = "array",
tickvals = c("Bajo", "Alto"),
ticktext = c(
"Hasta $1.300.000",
"Más de $1.300.000"
)
)
)
grafico_yNota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
# Líneas de tendencia central (media y mediana) para los histogramas.
# La media va en línea discontinua oscura y la mediana en línea continua celeste.
# Las etiquetas se abren hacia lados opuestos para que no se sobrepongan.
lineas_central <- function(x, dec = 1, yshift = 0) {
m <- mean(x, na.rm = TRUE)
md <- median(x, na.rm = TRUE)
fm <- function(v) formatC(v, format = "f", digits = dec,
big.mark = ".", decimal.mark = ",")
media_izq <- m < md
list(
shapes = list(
list(type = "line", x0 = m, x1 = m, y0 = 0, y1 = 1, yref = "paper",
line = list(color = "#2C2A4A", width = 2, dash = "dash")),
list(type = "line", x0 = md, x1 = md, y0 = 0, y1 = 1, yref = "paper",
line = list(color = "#1F9BC9", width = 2, dash = "solid"))
),
annotations = list(
list(x = m, y = 1, yref = "paper", yshift = yshift,
text = paste0("Media: ", fm(m)), showarrow = FALSE,
yanchor = "bottom", xanchor = if (media_izq) "right" else "left",
font = list(color = "#2C2A4A", size = 12)),
list(x = md, y = 1, yref = "paper", yshift = yshift,
text = paste0("Mediana: ", fm(md)), showarrow = FALSE,
yanchor = "bottom", xanchor = if (media_izq) "left" else "right",
font = list(color = "#1F9BC9", size = 12))
)
)
}El histograma muestra una distribución muy asimétrica: las frecuencias se concentran por debajo de $1.300.000 y decrecen a medida que aumenta el ingreso, con una cola derecha larga. El tramo con más trabajadores es el de $1.100.000 a $1.200.000 que corresponde a 3.441 personas, y no el del salario mínimo de 2024: 3.132 trabajadores (11,9 %) reportan exactamente $1.160.000, que fue el SMMLV de 2023. Esto sugiere que una parte de los ingresos de enero de 2024 corresponde a pagos anteriores al reajuste del salario mínimo, ante esto la GEIH indaga por el ingreso del mes anterior a la entrevista. En el tramo siguiente, de $1.300.000 a $1.400.000, hay 1.938 trabajadores, entre ellos los 1.672 que ganan exactamente el SMMLV de 2024. También se observan picos en valores redondos ($2.000.000, $2.500.000, $3.000.000, $4.000.000 y $5.000.000), típicos de ingresos declarados de manera aproximada. Para la visualización se omitieron 984 trabajadores (3,7 %) con ingresos superiores a $5.000.000. La media queda a la derecha de la mediana, y el salario mínimo se ubica entre ambas.
Gráfico 2. Distribución del ingreso laboral mensual (hasta $5.000.000)
limite_visual <- 5000000
tam_bin <- 100000
datos_ingreso_plot <- datos %>%
filter(!is.na(inglabo), inglabo >= 0, inglabo <= limite_visual)
lc_ing <- lineas_central(datos$inglabo, dec = 0, yshift = 20)
forma_smmlv <- list(
type = "line", x0 = SMMLV, x1 = SMMLV, y0 = 0, y1 = 1, yref = "paper",
line = list(color = "#E0457B", width = 2, dash = "dot")
)
nota_smmlv <- list(
x = SMMLV, y = 1, yref = "paper", text = "Salario mínimo",
showarrow = FALSE, yanchor = "bottom", xanchor = "center",
font = list(color = "#E0457B", size = 12)
)
grafico_ingreso <- plotly::plot_ly(
data = datos_ingreso_plot,
x = ~inglabo,
type = "histogram",
xbins = list(
start = 0,
end = limite_visual,
size = tam_bin
),
marker = list(
color = "#B9A7E8",
line = list(
color = "white",
width = 0.5
)
),
hovertemplate = paste(
"Ingreso laboral: %{x}<br>",
"Trabajadores: %{y}<extra></extra>"
),
showlegend = FALSE
) %>%
plotly::layout(
xaxis = list(
title = "Ingreso laboral mensual (COP)",
range = c(0, limite_visual),
tickprefix = "$",
tickformat = ",d",
gridcolor = "#F1EDF7",
zeroline = FALSE
),
yaxis = list(
title = "Número de trabajadores",
rangemode = "tozero",
gridcolor = "#ECE9F3",
zeroline = FALSE
),
shapes = c(list(forma_smmlv), lc_ing$shapes),
annotations = c(list(nota_smmlv), lc_ing$annotations),
plot_bgcolor = "white",
paper_bgcolor = "white",
separators = ",.",
margin = list(
l = 75,
r = 35,
t = 80,
b = 80
),
hoverlabel = list(
bgcolor = "white",
font = list(
size = 13,
color = "#30294F"
)
)
) %>%
plotly::config(
displaylogo = FALSE,
responsive = TRUE
)
grafico_ingresoNota: La línea punteada rosada marca el salario mínimo de $1.300.000; la línea discontinua oscura, la media; y la línea continua celeste, la mediana. Se omiten los ingresos superiores a $5.000.000 solo para la visualización (la media y la mediana se calculan con todos los datos). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Las variables cuantitativas describen a una fuerza laboral de edad intermedia, con trayectorias laborales muy heterogéneas:
Tabla 6. Estadísticas descriptivas de las variables cuantitativas
columnas <- c(
"edad",
"edad2",
"antig",
"horas"
)
etiquetas <- c(
"Edad (a\u00f1os)",
"Edad al cuadrado",
"Antig\u00fcedad laboral (meses)",
"Horas trabajadas semanalmente"
)
tabla_cuantitativas <- data.frame(
Variable = etiquetas,
Media = sapply(
datos[columnas], mean, na.rm = TRUE
),
Mediana = sapply(
datos[columnas], median, na.rm = TRUE
),
`Desv. est.` = sapply(
datos[columnas], sd, na.rm = TRUE
),
Minimo = sapply(
datos[columnas], min, na.rm = TRUE
),
Maximo = sapply(
datos[columnas], max, na.rm = TRUE
),
check.names = FALSE,
row.names = NULL
)
for (j in 2:ncol(tabla_cuantitativas)) {
tabla_cuantitativas[[j]] <- formatC(
tabla_cuantitativas[[j]],
format = "f",
digits = 1,
big.mark = ".",
decimal.mark = ","
)
}
names(tabla_cuantitativas) <- c(
"Variable",
"Media",
"Mediana",
"Desv. est.",
"M\u00ednimo",
"M\u00e1ximo"
)
DT::datatable(
tabla_cuantitativas,
rownames = FALSE,
options = list(
dom = "t",
paging = FALSE,
searching = FALSE,
ordering = FALSE,
autoWidth = TRUE
),
class = "stripe hover compact",
width = "100%"
)Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
# Histograma interactivo con líneas de media y mediana
histograma_var <- function(x, titulo_x, ancho, dec = 1) {
lc <- lineas_central(x, dec = dec)
plotly::plot_ly(
x = x,
type = "histogram",
xbins = list(size = ancho),
marker = list(
color = "#B9A7E8",
line = list(color = "white", width = 0.5)
),
hovertemplate = paste0(
titulo_x, ": %{x}<br>",
"Trabajadores: %{y}<extra></extra>"
),
showlegend = FALSE
) %>%
plotly::layout(
xaxis = list(
title = titulo_x,
gridcolor = "#F1EDF7",
zeroline = FALSE
),
yaxis = list(
title = "Número de trabajadores",
rangemode = "tozero",
gridcolor = "#ECE9F3",
zeroline = FALSE
),
shapes = lc$shapes,
annotations = lc$annotations,
plot_bgcolor = "white",
paper_bgcolor = "white",
separators = ",.",
margin = list(l = 75, r = 35, t = 50, b = 70),
font = list(
family = "Spectral, Georgia, serif",
color = "#30294F"
),
hoverlabel = list(
bgcolor = "white",
font = list(size = 13, color = "#30294F")
)
) %>%
plotly::config(
displaylogo = FALSE,
responsive = TRUE
)
}
nota_central <- "La línea discontinua oscura marca la media y la línea continua celeste, la mediana."La edad se distribuye de forma casi simétrica, con una ligera asimetría hacia las edades altas (la media con 41,1 años, supera levemente la mediana de 40,0 y en esta última puede visualizarse una leve estabilidad antes de la caída al iniciar la vejez). El 47,7 % de los trabajadores tienen entre 30 y 49 años, el 23,7 % es menor de 30 y el 28,5 % tiene 50 años o más; apenas el 11,2 % tiene 60 años o más donde se observa un decenso muy pronunciado que puede reflejar el impacto de la edad legal o habitual de jubilación en una empresa.
Gráfico 3. Distribución de la edad de los trabajadores
Nota: La línea discontinua oscura marca la media y la línea continua celeste, la mediana. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Al ser una transformación creciente de la edad, su distribución conserva la forma de la anterior, pero con una asimetría derecha más marcada (media de 1.879,5 frente a una mediana de 1.600,0) y una cola larga que corresponde a los trabajadores de mayor edad. No aporta información adicional por sí sola; su utilidad es permitir que el modelo capture una relación curva entre edad e ingreso.
Gráfico 4. Distribución de la edad al cuadrado
Nota: La línea discontinua oscura marca la media y la línea continua celeste, la mediana. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La antigüedad presenta una fuerte asimetría hacia la derecha, el 30,1 % de los ocupados lleva un año o menos, lo que puede denotar la existencia de nuevas contrataciones frecuentemente, en su empleo actual, el 52,3 % tres años o menos y el 21,4 % más de diez años, y el 12,3 % alcanza veinte años o más. La media con 84,9 meses queda muy por encima de la mediana de 36,0 meses, dejando una gran brecha entre las dos, esto puede deberse a la existencia de trabajadores con trayectorias largas en un mismo empleo que estén “jalando” el promedio hacia arriba.
Gráfico 5. Distribución de la antigüedad laboral
Nota: La línea discontinua oscura marca la media y la línea continua celeste, la mediana. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Las horas trabajadas se concentran en jornadas completas: el 30,9 % de los ocupados trabaja exactamente 48 horas semanales y el 16,1 % trabaja 40. El 17,4 % trabaja menos de 40 horas y el 22,5 % más de 48 horas excediendo la jornada estándar; el 11,9 % trabaja 60 horas o más. El grupo de trabajadores con jornadas cortas arrastra la media hacia abajo y la sitúa en 45,0, siendo menor que la mediana de 48,0, y provocando una cola hacia la izquierda.
Gráfico 6. Distribución de las horas trabajadas por semana
Nota: La línea discontinua oscura marca la media y la línea continua celeste, la mediana. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La matriz de correlación de Pearson permite ver qué tan linealmente se asocian las variables numéricas entre sí y con el ingreso. Tres hallazgos se destacan. Primero, las correlaciones de las variables explicativas con el ingreso son todas débiles: antigüedad (0,10), horas trabajadas (0,09), edad (0,04) y edad al cuadrado (0,03). Esto no significa que no exista relación: el ingreso es muy asimétrico y tiene valores extremos que afectan a la correlación de Pearson, y la relación con la edad no es lineal. Segundo, la edad y la edad al cuadrado están casi perfectamente correlacionadas (0,99), algo esperable por construcción; esto dificulta interpretar sus coeficientes por separado, pero no afecta las predicciones del Logit. Tercero, la edad y la antigüedad tienen una correlación positiva moderada (0,51), lo que indica que a mayor edad se acumula en promedio mayor permanencia en el empleo, y las correlaciones de las horas con la edad (−0,07) y con la antigüedad (−0,01) son prácticamente nulas.
Gráfico 7. Matriz de correlación de Pearson entre las variables numéricas
nombres_cor <- c(
inglabo = "Ingreso laboral",
edad = "Edad",
edad2 = "Edad al cuadrado",
antig = "Antigüedad",
horas = "Horas trabajadas"
)
mat_cor <- cor(datos[names(nombres_cor)], use = "complete.obs")
etq_cor <- unname(nombres_cor)
dimnames(mat_cor) <- list(etq_cor, etq_cor)
anot_cor <- list()
for (i in seq_along(etq_cor)) {
for (j in seq_along(etq_cor)) {
anot_cor[[length(anot_cor) + 1]] <- list(
x = etq_cor[j], y = etq_cor[i],
text = formatC(mat_cor[i, j], format = "f", digits = 2, decimal.mark = ","),
showarrow = FALSE,
font = list(size = 14,
color = if (abs(mat_cor[i, j]) > 0.6) "white" else "#2C2A4A")
)
}
}
plotly::plot_ly(
x = etq_cor, y = etq_cor, z = mat_cor,
type = "heatmap",
zmin = -1, zmax = 1,
colorscale = list(c(0, "#7FDEFF"), c(0.5, "#FFFFFF"), c(1, "#4F518C")),
xgap = 3, ygap = 3,
hovertemplate = "%{y} vs %{x}<br>Correlación: %{z:.3f}<extra></extra>",
colorbar = list(title = "Correlación")
) %>%
plotly::layout(
xaxis = list(title = "", tickangle = -25, showgrid = FALSE),
yaxis = list(title = "", autorange = "reversed", showgrid = FALSE),
annotations = anot_cor,
separators = ",.",
plot_bgcolor = "white",
paper_bgcolor = "white",
margin = list(l = 130, r = 30, t = 20, b = 110),
font = list(family = "Spectral, Georgia, serif", color = "#30294F")
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)Nota: Edad y edad al cuadrado están casi perfectamente correlacionadas por construcción. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
# Boxplot interactivo de una variable cuantitativa según nivel de ingreso.
# El rombo indica la media; la línea dentro de la caja, la mediana.
boxplot_nivel <- function(var, titulo_y, margen_l = 85,
eje_extra = list(rangemode = "tozero")) {
if (!all(c(var, "y") %in% names(datos))) {
stop("Faltan las variables ", var, " o y")
}
d <- datos %>%
dplyr::select(valor = dplyr::all_of(var), y) %>%
dplyr::filter(!is.na(valor), !is.na(y))
estilos <- list(
Bajo = list(nombre = "Ingreso Bajo", relleno = "#DABFFF", linea = "#907AD6"),
Alto = list(nombre = "Ingreso Alto", relleno = "#907AD6", linea = "#4F518C")
)
g <- plotly::plot_ly()
for (nivel in c("Bajo", "Alto")) {
vals <- d$valor[d$y == nivel]
est <- estilos[[nivel]]
g <- plotly::add_boxplot(
g,
x = rep(nivel, length(vals)),
y = vals,
name = est$nombre,
fillcolor = est$relleno,
line = list(color = est$linea, width = 2),
boxmean = TRUE,
boxpoints = FALSE,
showlegend = FALSE
)
}
eje_y <- c(
list(title = titulo_y, tickformat = ".0f", gridcolor = "#ECE9F3", zeroline = FALSE),
eje_extra
)
g %>%
plotly::layout(
xaxis = list(
title = "Nivel de ingreso laboral",
type = "category",
categoryorder = "array",
categoryarray = c("Bajo", "Alto"),
showgrid = FALSE
),
yaxis = eje_y,
plot_bgcolor = "white",
paper_bgcolor = "white",
separators = ",.",
margin = list(l = margen_l, r = 40, t = 30, b = 80),
font = list(family = "Spectral, Georgia, serif", color = "#30294F")
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)
}Los trabajadores con ingreso alto tienen una edad media apenas mayor (41,6 años) que los de ingreso bajo (40,9) y una mediana de 40 frente a 39 años. Las cajas casi se superponen: la mitad central del grupo Alto tiene entre 32 y 51 años, y la del grupo Bajo, entre 29 y 52. La diferencia más visible está en el extremo inferior, pues el grupo Bajo incluye una proporción algo mayor de trabajadores jóvenes (primer cuartil de 29 años frente a 32). La edad por sí sola discrimina poco entre ambos grupos, lo que concuerda con su baja correlación con el ingreso.
Gráfico 8. Distribución de la edad según nivel de ingreso laboral
Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). El rombo indica la media. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
El patrón es el mismo que con la edad, en otra escala: la media de edad al cuadrado es casi idéntica entre grupos (1.886,3 en Alto y 1.875,7 en Bajo), con medianas de 1.600 y 1.521, respectivamente. Las distribuciones se superponen en gran medida, por lo que esta variable tampoco separa por sí sola a los dos grupos.
Gráfico 9. Distribución de la edad al cuadrado según nivel de ingreso laboral
Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). El rombo indica la media. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La antigüedad sí muestra una diferencia visible: los trabajadores con ingreso alto tienen una media de 99,7 meses y una mediana de 60, frente a 76,7 y 36 meses en el grupo de ingreso bajo, y también un mayor tercer cuartil (144 frente a 96 meses). Una permanencia más larga en el empleo se asocia, por tanto, con una mayor probabilidad de superar el salario mínimo. Aun así, ambas distribuciones son muy asimétricas y se superponen, y parte de esta diferencia puede reflejar que la antigüedad crece con la edad (correlación de 0,51) y varía según el tipo de empleo.
Gráfico 10. Distribución de la antigüedad laboral según nivel de ingreso
Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). El rombo indica la media. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Las medianas son iguales (48 horas) y el rango intercuartílico es el mismo (40 a 48 horas) en ambos grupos; la diferencia está en las jornadas cortas. El 22,8 % de los trabajadores de ingreso bajo trabaja menos de 40 horas semanales, frente al 7,5 % de los de ingreso alto, y el promedio de horas es de 47,6 en el grupo Alto frente a 43,6 en el Bajo. En cambio, las jornadas largas (más de 48 horas) son similares en ambos grupos (23,7 % en Alto y 21,8 % en Bajo). Es decir, trabajar jornada parcial se asocia con ingresos bajos, pero trabajar más de la jornada completa no garantiza ingresos altos.
Gráfico 11. Horas trabajadas por semana según nivel de ingreso laboral
boxplot_nivel(
"horas", "Horas trabajadas por semana", margen_l = 85,
eje_extra = list(range = c(0, 105), dtick = 10, hoverformat = ".1f")
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). El rombo indica la media. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
resumen_cat_desc <- function(x) {
if (anyNA(x)) {
stop("Hay valores faltantes en la variable")
}
f <- table(x)
f <- f[f > 0]
data.frame(
Categoria = names(f),
N = as.integer(f),
Porcentaje = 100 * as.numeric(f) / sum(f),
row.names = NULL
)
}
mostrar_cat_desc <- function(tabla) {
DT::datatable(
tabla,
rownames = FALSE,
colnames = c(
"Categoría",
"Frecuencia absoluta (N)",
"Frecuencia relativa (%)"
),
options = list(
dom = "t",
paging = FALSE,
searching = FALSE,
ordering = FALSE,
autoWidth = FALSE,
columnDefs = list(
list(
targets = 0,
width = "55%"
),
list(
targets = c(1, 2),
width = "22%",
className = "dt-right"
)
)
),
class = "stripe hover compact",
width = "100%"
) %>%
DT::formatRound(
columns = "N",
digits = 0,
mark = ".",
dec.mark = ","
) %>%
DT::formatRound(
columns = "Porcentaje",
digits = 1,
mark = ".",
dec.mark = ","
)
}
# Barras apiladas (100 %) del nivel de ingreso dentro de cada categoría
grafico_barras_ingreso <- function(var, titulo_x, margen,
orden = c("alto", "niveles"),
min_etiqueta = 0, tam_texto = 12, tam_y = 12,
bargap = 0.30, constraint = "inside",
grupo = "grupo") {
orden <- match.arg(orden)
if (!all(c(var, "y") %in% names(datos))) stop("Faltan las variables ", var, " o y")
if (anyNA(datos[[var]]) || anyNA(datos$y)) stop("Existen registros sin clasificar")
if (!is.factor(datos[[var]])) stop("La variable ", var, " debe ser un factor")
if (!identical(levels(datos$y), c("Bajo", "Alto"))) stop("Revisar las categorias de ingreso")
tab <- datos %>%
dplyr::count(Grupo = .data[[var]], y, name = "Trabajadores", .drop = FALSE) %>%
dplyr::group_by(Grupo) %>%
dplyr::mutate(
Total_grupo = sum(Trabajadores),
Porcentaje = dplyr::if_else(Total_grupo > 0, 100 * Trabajadores / Total_grupo, 0)
) %>%
dplyr::ungroup() %>%
dplyr::filter(Total_grupo > 0)
# controles de consistencia
ctrl <- tab %>% dplyr::group_by(Grupo) %>% dplyr::summarise(p = sum(Porcentaje), .groups = "drop")
if (any(abs(ctrl$p - 100) > 1e-8)) stop("Los porcentajes no suman 100%")
if (sum(tab$Trabajadores) != nrow(datos)) stop("El total de trabajadores no coincide")
orden_cat <- if (orden == "alto") {
tab %>% dplyr::filter(y == "Alto") %>% dplyr::arrange(dplyr::desc(Porcentaje)) %>%
dplyr::pull(Grupo) %>% as.character()
} else {
levels(datos[[var]])
}
nombre_var <- names(which(unlist(vars_cat) == var))
tab <- tab %>%
dplyr::mutate(
Etiqueta = dplyr::if_else(
Porcentaje >= min_etiqueta,
paste0(formatC(Porcentaje, format = "f", digits = 1, decimal.mark = ","), "%"),
""
),
Detalle = paste0(
"<b>", nombre_var, ": ", Grupo, "</b>",
"<br>Nivel de ingreso: ", y,
"<br>Trabajadores: ", formatC(Trabajadores, format = "d", big.mark = "."),
"<br>Porcentaje dentro del ", grupo, ": ",
formatC(Porcentaje, format = "f", digits = 2, decimal.mark = ","), "%",
"<br>Total del ", grupo, ": ", formatC(Total_grupo, format = "d", big.mark = "."),
"<extra></extra>"
)
)
agrega <- function(p, nivel, nombre, relleno, color_texto) {
plotly::add_bars(
p,
data = dplyr::filter(tab, y == nivel),
x = ~Porcentaje, y = ~Grupo,
name = nombre, orientation = "h",
marker = list(color = relleno, line = list(color = "white", width = 1)),
text = ~Etiqueta, textposition = "inside", insidetextanchor = "middle",
constraintext = constraint,
textfont = list(size = tam_texto, color = color_texto),
hovertemplate = ~Detalle
)
}
plotly::plot_ly() %>%
agrega("Bajo", "Ingreso Bajo", "#DABFFF", "#30294F") %>%
agrega("Alto", "Ingreso Alto", "#907AD6", "white") %>%
plotly::layout(
barmode = "stack",
xaxis = list(
title = titulo_x, range = c(0, 100), dtick = 20, ticksuffix = "%",
gridcolor = "#ECE9F3", zeroline = FALSE
),
yaxis = list(
title = "", type = "category", categoryorder = "array",
categoryarray = rev(orden_cat), showgrid = FALSE,
tickfont = list(size = tam_y), automargin = TRUE
),
legend = list(orientation = "h", x = 0.5, xanchor = "center",
y = -0.30, traceorder = "normal"),
bargap = bargap,
plot_bgcolor = "white",
paper_bgcolor = "white",
margin = margen,
separators = ",.",
font = list(family = "Spectral, Georgia, serif", color = "#30294F"),
hoverlabel = list(bgcolor = "white", font = list(size = 12, color = "#30294F"))
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)
}La tabla cruza cada característica categórica con el nivel de ingreso. Los porcentajes se calculan sobre el total de trabajadores de la base, de modo que dentro de cada variable suman 100 %: por ejemplo, los 8.816 trabajadores urbanos con ingreso alto equivalen al 33,4 % del total, y las 3.830 mujeres con ingreso alto, al 14,5 %. Por ser porcentajes del total, no sirven para comparar la probabilidad de ingreso alto entre categorías de distinto tamaño; esa comparación se hace en los gráficos siguientes, que usan el porcentaje dentro de cada categoría.
Tabla 7. Distribución de los trabajadores por característica y nivel de ingreso
vars_cat <- list(
"Nivel educativo" = "educ_f",
"Tipo de empleo" = "tipo_emp",
"Sexo" = "sexo_f",
"Zona de residencia" = "zona_f",
"Estado civil" = "est_civil_f"
)
tabla_cat_desglose <- purrr::imap_dfr(vars_cat, function(col, nombre) {
datos %>%
dplyr::count(
Caracteristica = .data[[col]],
Ingreso = y,
name = "N",
.drop = FALSE
) %>%
dplyr::mutate(
Variable = nombre,
Caracteristica = as.character(Caracteristica),
Ingreso = as.character(Ingreso)
)
}) %>%
dplyr::transmute(
Variable,
`Característica` = Caracteristica,
`Categoría de ingreso` = Ingreso,
Frecuencia = fmt_miles(N),
Porcentaje = paste0(fmt_miles(100 * N / nrow(datos), 2), "%")
)
tabla(tabla_cat_desglose) %>%
kableExtra::collapse_rows(columns = 1:2, valign = "top")| Variable | Característica | Categoría de ingreso | Frecuencia | Porcentaje |
|---|---|---|---|---|
| Nivel educativo | Primaria | Bajo | 4.125 | 15,62% |
| Alto | 655 | 2,48% | ||
| Secundaria | Bajo | 9.368 | 35,47% | |
| Alto | 2.787 | 10,55% | ||
| Técnica/Tecnología | Bajo | 2.146 | 8,13% | |
| Alto | 1.630 | 6,17% | ||
| Universitaria | Bajo | 1.310 | 4,96% | |
| Alto | 2.871 | 10,87% | ||
| Posgrado | Bajo | 80 | 0,30% | |
| Alto | 1.436 | 5,44% | ||
| Tipo de empleo | Empleado empresa particular | Bajo | 6.979 | 26,43% |
| Alto | 4.693 | 17,77% | ||
| Empleado del gobierno | Bajo | 85 | 0,32% | |
| Alto | 1.425 | 5,40% | ||
| Empleado doméstico | Bajo | 747 | 2,83% | |
| Alto | 161 | 0,61% | ||
| Cuenta propia | Bajo | 8.519 | 32,26% | |
| Alto | 2.553 | 9,67% | ||
| Patrón o empleador | Bajo | 156 | 0,59% | |
| Alto | 455 | 1,72% | ||
| Otros (familiar sin pago / jornalero) | Bajo | 543 | 2,06% | |
| Alto | 92 | 0,35% | ||
| Sexo | Hombre | Bajo | 9.414 | 35,65% |
| Alto | 5.549 | 21,01% | ||
| Mujer | Bajo | 7.615 | 28,84% | |
| Alto | 3.830 | 14,50% | ||
| Zona de residencia | Urbano | Bajo | 14.652 | 55,48% |
| Alto | 8.816 | 33,38% | ||
| Rural | Bajo | 2.377 | 9,00% | |
| Alto | 563 | 2,13% | ||
| Estado civil | Unión libre (<2 años) | Bajo | 435 | 1,65% |
| Alto | 240 | 0,91% | ||
| Unión libre (>=2 años) | Bajo | 5.713 | 21,63% | |
| Alto | 2.706 | 10,25% | ||
| Casado(a) | Bajo | 2.544 | 9,63% | |
| Alto | 2.547 | 9,64% | ||
| Separado(a) / Divorciado(a) | Bajo | 3.134 | 11,87% | |
| Alto | 1.283 | 4,86% | ||
| Viudo(a) | Bajo | 429 | 1,62% | |
| Alto | 124 | 0,47% | ||
| Soltero(a) | Bajo | 4.774 | 18,08% | |
| Alto | 2.479 | 9,39% |
Nota: El porcentaje se calcula sobre el total de trabajadores de la base depurada. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
El nivel educativo se mide con cinco categorías. La mayoría de los ocupados tiene educación secundaria (46,0 %; 12.155 trabajadores), seguida de primaria (18,1 %), universitaria (15,8 %), técnica o tecnológica (14,3 %) y posgrado (5,7 %; 1.516 trabajadores). En conjunto, el 64,1 % tiene secundaria o menos y el 35,9 % alcanzó algún nivel de educación superior.
Tabla 8. Distribución de los trabajadores según nivel educativo
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
El nivel educativo presenta la asociación más fuerte con el ingreso y un gradiente creciente: la proporción de trabajadores con ingreso alto es de 13,7 % en Primaria, 22,9 % en Secundaria, 43,2 % en Técnica/Tecnología, 68,7 % en Universitaria y 94,7 % en Posgrado, es decir, 81 puntos porcentuales más entre los extremos. Solo en Primaria más del 80 % de los trabajadores tiene ingreso bajo (86,3 %); en Secundaria esa proporción es de 77,1 %. En Posgrado, en cambio, apenas 80 trabajadores (5,3 %) ganan hasta el salario mínimo. Con el nivel universitario, la mayoría de los trabajadores supera el SMMLV.
Gráfico 12. Nivel de ingreso laboral según nivel educativo
grafico_barras_ingreso(
"educ_f", "Porcentaje de trabajadores por nivel educativo",
margen = list(l = 155, r = 135, t = 30, b = 110),
orden = "alto", tam_texto = 11, constraint = "none"
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
En dos categorías se concentran la gran mayoría de los ocupados: los empleados de empresa particular (44,2 %; 11.672 trabajadores) y los trabajadores por cuenta propia (41,9 %; 11.072), que juntos suman el 86,1 %. Siguen los empleados del gobierno (5,7 %), los empleados domésticos (3,4 %), la categoría «Otros» (2,4 %) y los patrones o empleadores (2,3 %). Las cuatro últimas tienen menos de 1.600 observaciones cada una, lo que conviene recordar al interpretar sus resultados.
Tabla 9. Distribución de los trabajadores según tipo de empleo
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Existen brechas marcadas según la posición ocupacional. Los empleados del gobierno (94,4 %) y los patrones o empleadores (74,5 %) son los grupos con mayor proporción de ingreso alto. Los empleados de empresa particular se ubican en un punto intermedio (40,2 %), por encima del promedio general (35,5 %). Por debajo están los trabajadores por cuenta propia (23,1 %), los empleados domésticos (17,7 %) y la categoría «Otros» (14,5 %); en todos ellos la gran mayoría gana hasta el salario mínimo (entre 76,9 % y 85,5 %). Como los cuenta propia son el 41,9 % de los ocupados, su bajo porcentaje de ingreso alto pesa mucho en el resultado general.
Gráfico 13. Nivel de ingreso laboral según tipo de empleo
grafico_barras_ingreso(
"tipo_emp", "Porcentaje de trabajadores por tipo de empleo",
margen = list(l = 190, r = 110, t = 30, b = 115),
orden = "alto", tam_y = 11
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La base contiene más hombres (56,7 %; 14.963) que mujeres (43,3 %; 11.445).
Tabla 10. Distribución de los trabajadores según sexo
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Los hombres tienen una proporción algo mayor de ingreso alto (37,1 %) que las mujeres (33,5 %), una brecha de 3,6 puntos porcentuales. En términos de ingreso, el promedio de los hombres es de $1.716.226 y el de las mujeres de $1.547.450 (medianas de $1.200.000 y $1.160.000). La brecha descriptiva es moderada porque dos efectos opuestos se mezclan: las mujeres de la base tienen en promedio más educación (26,5 % con nivel universitario o posgrado, frente a 17,8 % de los hombres), pero trabajan menos horas semanales (42,1 frente a 47,3). Por eso conviene mirar el efecto del sexo una vez controladas las demás variables, algo que hace el modelo Logit.
Gráfico 14. Nivel de ingreso laboral según sexo
grafico_barras_ingreso(
"sexo_f", "Porcentaje de trabajadores por sexo",
margen = list(l = 100, r = 35, t = 30, b = 125),
orden = "niveles", min_etiqueta = 7, tam_texto = 13, tam_y = 13, bargap = 0.40
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La gran mayoría de los ocupados reside en zonas urbanas (88,9 %; 23.468 trabajadores), y el 11,1 % (2.940) en zonas rurales.
Tabla 11. Distribución de los trabajadores según zona de residencia
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Se observa una disparidad urbano-rural clara: el 37,6 % de los trabajadores urbanos tiene ingreso alto frente al 19,1 % de los rurales, es decir, una diferencia de 18,5 puntos porcentuales. En la zona rural prevalece ampliamente la categoría Bajo (80,9 %). Esta brecha descriptiva puede reflejar, en parte, diferencias de educación y de tipo de ocupación entre ambas zonas.
Gráfico 15. Nivel de ingreso laboral según zona de residencia
grafico_barras_ingreso(
"zona_f", "Porcentaje de trabajadores por zona",
margen = list(l = 115, r = 35, t = 30, b = 125),
orden = "alto", tam_texto = 13, tam_y = 13, bargap = 0.40
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Las categorías más numerosas son la unión libre de dos años o más (31,9 %; 8.419 trabajadores), los solteros (27,5 %) y los casados (19,3 %), seguidos de los separados o divorciados (16,7 %). Las categorías más pequeñas son la unión libre de menos de dos años (2,6 %; 675) y los viudos (2,1 %; 553).
Tabla 12. Distribución de los trabajadores según estado civil
Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Los casados son el único grupo en que la mitad de los trabajadores tiene ingreso alto (50,0 %). Les siguen los de unión libre de menos de dos años (35,6 %), los solteros (34,2 %) y los de unión libre de dos años o más (32,1 %); estos dos últimos quedan por debajo del promedio general (35,5 %), y más abajo están los separados o divorciados (29,0 %) y los viudos (22,4 %). La ventaja de los casados está relacionada en parte con su mayor edad (edad media de 48,8 años frente a 33,4 de los solteros), aunque la edad por sí sola discrimina poco entre grupos de ingreso. Las categorías pequeñas (unión libre de menos de dos años y viudos) deben interpretarse con cautela.
Gráfico 16. Nivel de ingreso laboral según estado civil
grafico_barras_ingreso(
"est_civil_f", "Porcentaje de trabajadores por estado civil",
margen = list(l = 170, r = 110, t = 30, b = 115),
orden = "alto", min_etiqueta = 7, tam_y = 11, grupo = "categoría"
)Nota: Ingreso alto: superior al SMMLV de 2024 ($1.300.000). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
vars_modelo <- c("edad", "edad2", "antig", "horas", "educ_f", "tipo_emp",
"sexo_f", "zona_f", "est_civil_f", "y")
faltan <- setdiff(vars_modelo, names(datos))
if (length(faltan) > 0) stop("Faltan columnas en 'datos': ", paste(faltan, collapse = ", "))Tabla 13. Distribución de las observaciones en los conjuntos de entrenamiento y prueba
set.seed(2025)
idx <- createDataPartition(datos$y, p = 0.70, list = FALSE)
train <- datos[idx, ] %>% as.data.frame()
test <- datos[-idx, ] %>% as.data.frame()
tibble(
Conjunto = c("Entrenamiento", "Prueba"),
Observaciones = c(nrow(train), nrow(test)),
Bajo = c(sum(train$y == "Bajo"), sum(test$y == "Bajo")),
Alto = c(sum(train$y == "Alto"), sum(test$y == "Alto")),
`% Alto` = percent(c(mean(train$y == "Alto"), mean(test$y == "Alto")),
accuracy = 0.1, decimal.mark = ",")
) %>%
tabla(format.args = list(big.mark = "."))| Conjunto | Observaciones | Bajo | Alto | % Alto |
|---|---|---|---|---|
| Entrenamiento | 18.487 | 11.921 | 6.566 | 35,5% |
| Prueba | 7.921 | 5.108 | 2.813 | 35,5% |
Nota: Partición estratificada por nivel de ingreso (70 % entrenamiento, 30 % prueba). Ambos modelos usan exactamente los mismos conjuntos y las mismas variables explicativas. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Para evaluar los modelos con datos que no usaron en su ajuste, la base se dividió de manera aleatoria (semilla 2025) en un conjunto de entrenamiento con el 70 % de las observaciones (18.487) y uno de prueba con el 30 % (7.921). El muestreo es estratificado por nivel de ingreso, de modo que ambos conjuntos conservan la proporción de la clase «Alto» (35,5% en entrenamiento y 35,5% en prueba, frente a 35,5% en la base completa). Los modelos se ajustan solo con el entrenamiento y se evalúan con la prueba, lo que evita sobreestimar su desempeño.
ref_es_alto <- test$y == "Alto"
evalua <- function(prob, real, umbral = 0.5, modelo = "", auc_val = NA) {
prob_num <- as.numeric(prob)
real_vec <- as.character(real)
pred <- factor(if_else(prob_num >= umbral, "Alto", "Bajo"), levels = c("Bajo", "Alto"))
ref <- factor(real_vec, levels = c("Bajo", "Alto"))
cm <- confusionMatrix(pred, ref, positive = "Alto")
tibble(
Modelo = modelo,
Exactitud = as.numeric(cm$overall["Accuracy"]),
Sensibilidad = as.numeric(cm$byClass["Sensitivity"]),
Especificidad = as.numeric(cm$byClass["Specificity"]),
Precisión = as.numeric(cm$byClass["Pos Pred Value"]),
`Balanced Accuracy` = as.numeric(cm$byClass["Balanced Accuracy"]),
Kappa = as.numeric(cm$overall["Kappa"]),
AUC = as.numeric(auc_val)
) %>%
mutate(across(where(is.numeric), ~ round(.x, 4)))
}
# Matriz de confusión como tabla (filas = predicción, columnas = valor real)
tabla_confusion <- function(pred, real) {
m <- table(Predicción = pred, Real = real)
df <- data.frame(
"Predicción" = rownames(m),
Bajo = as.integer(m[, "Bajo"]),
Alto = as.integer(m[, "Alto"]),
check.names = FALSE
)
kable(df, align = "lcc", format.args = list(big.mark = ".")) %>%
kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) %>%
# los dos niveles del encabezado comparten color, así la esquina superior
# izquierda no queda como un hueco blanco
add_header_above(c(" " = 1, "Valor real" = 2),
bold = TRUE, color = "white", background = "#4F518C") %>%
row_spec(0, bold = TRUE, color = "white", background = "#4F518C") %>%
column_spec(1, bold = TRUE, background = "#F4EDFF")
}
# Métricas ampliadas de un modelo (como en el análisis por modelo)
metricas_ext <- function(pred, real, auc_val) {
cm <- confusionMatrix(pred, real, positive = "Alto")
ov <- cm$overall
bc <- cm$byClass
f4 <- function(x) formatC(as.numeric(x), format = "f", digits = 4, decimal.mark = ",")
fe <- function(x) formatC(as.numeric(x), format = "e", digits = 2, decimal.mark = ",")
data.frame(
"Métrica" = c("Exactitud (Accuracy)", "Intervalo de confianza 95 %",
"Tasa de no información", "Valor p [Exactitud > TNI]",
"Kappa de Cohen", "Valor p de McNemar",
"Sensibilidad (Recall)", "Especificidad",
"Valor predictivo positivo (Precisión)", "Valor predictivo negativo",
"Exactitud balanceada", "AUC"),
"Descripción" = c("Proporción total de predicciones correctas",
"Intervalo de confianza para la exactitud",
"Exactitud al predecir siempre la clase mayoritaria",
"Significancia estadística frente a la tasa base",
"Acuerdo entre lo observado y lo predicho, ajustado por azar",
"Asimetría entre falsos positivos y falsos negativos",
"Capacidad de detectar ingresos altos (VP / VP + FN)",
"Capacidad de detectar ingresos bajos (VN / VN + FP)",
"Acierto cuando se predice ingreso alto (VP / VP + FP)",
"Acierto cuando se predice ingreso bajo (VN / VN + FN)",
"Promedio entre sensibilidad y especificidad",
"Capacidad de discriminación sin depender del umbral"),
Valor = c(f4(ov["Accuracy"]),
paste0("(", f4(ov["AccuracyLower"]), "; ", f4(ov["AccuracyUpper"]), ")"),
f4(ov["AccuracyNull"]), fe(ov["AccuracyPValue"]),
f4(ov["Kappa"]), fe(ov["McnemarPValue"]),
f4(bc["Sensitivity"]), f4(bc["Specificity"]),
f4(bc["Pos Pred Value"]), f4(bc["Neg Pred Value"]),
f4(bc["Balanced Accuracy"]), f4(auc_val)),
check.names = FALSE
)
}
perf_umbral_rapido <- function(cutoff, prob, ref_alto) {
pred_alto <- prob >= cutoff
tp <- sum(pred_alto & ref_alto)
tn <- sum(!pred_alto & !ref_alto)
fp <- sum(pred_alto & !ref_alto)
fn <- sum(!pred_alto & ref_alto)
sens <- if ((tp + fn) > 0) tp / (tp + fn) else 0
espec <- if ((tn + fp) > 0) tn / (tn + fp) else 0
prec <- if ((tp + fp) > 0) tp / (tp + fp) else 0
acc <- (tp + tn) / length(ref_alto)
tibble(Umbral = cutoff, Sensibilidad = sens, Exactitud = acc,
Precisión = prec, Especificidad = espec)
}
youden <- function(r) {
as.numeric(unlist(coords(r, x = "best", best.method = "youden", ret = "threshold")))[1]
}
nombre_term <- function(x) {
x %>%
str_replace("^\\(Intercept\\)$", "Intercepto") %>%
str_replace("^edad2$", "Edad al cuadrado") %>%
str_replace("^edad$", "Edad") %>%
str_replace("^antig$", "Antigüedad (meses)") %>%
str_replace("^horas$", "Horas semanales") %>%
str_replace("^educ_f", "Educación: ") %>%
str_replace("^tipo_emp", "Tipo de empleo: ") %>%
str_replace("^sexo_f", "Sexo: ") %>%
str_replace("^zona_f", "Zona: ") %>%
str_replace("^est_civil_f", "Estado civil: ")
}KNN clasifica según distancias entre observaciones, por lo que sus variables numéricas se estandarizan (puntaje Z con la media y la desviación estándar del conjunto de entrenamiento, que luego se aplican al de prueba). El Logit no lo necesita: se ajusta con las variables en sus unidades originales (años, meses, horas) para conservar la interpretación de los odds ratios; estandarizar solo cambiaría la escala de sus coeficientes, no sus probabilidades predichas. Lo que hace válida la comparación es que ambos modelos usan los mismos datos de entrenamiento y de prueba y las mismas variables explicativas.
f_x <- ~ edad + edad2 + antig + horas + educ_f + tipo_emp +
sexo_f + zona_f + est_civil_f
X_train <- model.matrix(f_x, data = train)[, -1]
X_test <- model.matrix(f_x, data = test)[, -1]
const <- apply(X_train, 2, sd) == 0
X_train <- X_train[, !const, drop = FALSE]
X_test <- X_test[, !const, drop = FALSE]
# las numéricas se estandarizan con la media y desviación del entrenamiento
vars_num <- c("edad", "edad2", "antig", "horas")
medias <- colMeans(X_train[, vars_num])
desv <- apply(X_train[, vars_num], 2, sd)
X_train[, vars_num] <- scale(X_train[, vars_num], center = medias, scale = desv)
X_test[, vars_num] <- scale(X_test[, vars_num], center = medias, scale = desv)# k impares para evitar empates; los vecinos se calculan una vez por partición
set.seed(2025)
ks <- seq(1, 101, by = 2)
folds <- createFolds(train$y, k = 5, list = TRUE, returnTrain = FALSE)
y_alto <- train$y == "Alto"
acc_folds <- sapply(folds, function(i_val) {
nn <- FNN::get.knnx(data = X_train[-i_val, , drop = FALSE],
query = X_train[i_val, , drop = FALSE],
k = max(ks), algorithm = "brute")
y_tr <- y_alto[-i_val]
votos <- t(apply(nn$nn.index, 1, function(i) cumsum(y_tr[i])))
sapply(ks, function(k) mean((votos[, k] > k / 2) == y_alto[i_val]))
})
res_class <- tibble(k = ks, Exactitud = rowMeans(acc_folds))
k_opt_class <- res_class$k[which.max(res_class$Exactitud)]
acc_opt <- max(res_class$Exactitud)KNN clasifica a cada trabajador según la categoría mayoritaria entre sus k vecinos más cercanos, medidos con la distancia euclidiana sobre las variables estandarizadas y las variables categóricas codificadas como indicadoras (Cover & Hart, 1967). El valor de k controla la flexibilidad del modelo: con valores pequeños las fronteras de decisión son irregulares y sensibles al ruido (sobreajuste), y con valores muy grandes se suavizan en exceso. Para elegirlo se calculó la exactitud promedio en una validación cruzada de cinco particiones, dentro del conjunto de entrenamiento, para los valores impares de k entre 1 y 101 (impares para evitar empates en la votación).
Con k = 1 la exactitud es de 69,9%; mejora con rapidez al aumentar k y luego se estabiliza en una meseta. El máximo se alcanza con k = 59, con una exactitud de 76,92%. Cerca del óptimo la curva es casi plana, de modo que la elección exacta de k es poco crítica: valores vecinos producen resultados muy parecidos.
Gráfico 17. Selección del número de vecinos (k) del modelo KNN por validación cruzada
res_class <- res_class %>%
mutate(
tooltip = paste0(
"k = ", k,
"<br>Exactitud (CV): ", round(Exactitud, 4)
)
)
p_acc <- ggplot(
res_class,
aes(k, Exactitud, text = tooltip, group = 1)
) +
geom_line(
colour = pal[["lake"]],
linewidth = 0.8
) +
geom_point(
colour = pal[["lake"]],
size = 1.6
) +
geom_vline(
xintercept = k_opt_class,
linetype = "dashed",
colour = pal[["indigo"]]
) +
geom_point(
data = filter(res_class, k == k_opt_class),
colour = pal[["periwinkle"]],
size = 4
) +
labs(
x = "Número de vecinos (k)",
y = "Exactitud promedio en validación cruzada"
)
# Aviso estático (siempre visible) que señala el punto del k óptimo.
# Se ubica hacia el lado con más espacio según dónde caiga k.
aviso_k <- list(
x = k_opt_class, y = acc_opt, xref = "x", yref = "y",
text = paste0("<b>k óptimo = ", k_opt_class, "</b><br>Exactitud (CV): ",
pct(acc_opt, 0.01)),
showarrow = TRUE, arrowhead = 2, arrowsize = 1.2, arrowwidth = 1.5,
arrowcolor = pal[["indigo"]],
ax = if (k_opt_class > 50) -85 else 85,
ay = -60,
bgcolor = "white", bordercolor = pal[["periwinkle"]],
borderwidth = 1.5, borderpad = 5,
font = list(color = pal[["indigo"]], size = 13)
)
grafico_knn <- interactivo(
p_acc,
tooltip = "text"
) %>%
plotly::layout(
height = 480,
margin = list(
l = 115,
r = 35,
t = 40,
b = 95
),
xaxis = list(
title = list(
text = "Número de vecinos (k)",
standoff = 18
),
automargin = TRUE
),
yaxis = list(
title = list(
text = "Exactitud promedio en validación cruzada",
standoff = 15
),
range = c(min(res_class$Exactitud) - 0.005, acc_opt + 0.02),
automargin = TRUE
),
annotations = list(aviso_k)
)
grafico_knnNota: Validación cruzada estratificada de 5 particiones sobre la muestra de entrenamiento, con k impar de 1 a 101. La línea punteada marca el k óptimo (k = 59). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
pred_knn <- class::knn(train = X_train, test = X_test, cl = train$y,
k = k_opt_class, prob = TRUE)
prob_win <- as.numeric(attr(pred_knn, "prob"))
pred_vec <- as.character(pred_knn)
p_knn <- as.numeric(if_else(pred_vec == "Alto", prob_win, 1 - prob_win))
pred_knn <- factor(pred_vec, levels = c("Bajo", "Alto"))
roc_knn <- roc(test$y, p_knn, levels = c("Bajo", "Alto"), direction = "<", quiet = TRUE)
auc_knn <- as.numeric(auc(roc_knn))El número de vecinos seleccionado fue k = 59, con una exactitud promedio en validación cruzada de 76,9%. Con ese valor se ajustó el modelo sobre todo el conjunto de entrenamiento y se clasificó el conjunto de prueba, tomando “Alto” como clase positiva.
Tabla 14. Matriz de confusión del modelo KNN
| Predicción | Bajo | Alto |
|---|---|---|
| Bajo | 4.743 | 1.385 |
| Alto | 365 | 1.428 |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Los valores de la diagonal son las clasificaciones correctas. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La matriz resume cómo clasificó el KNN a los 7.921 trabajadores del conjunto de prueba. El modelo acertó en 6.171 casos (4.743 verdaderos negativos y 1.428 verdaderos positivos) y se equivocó en 1.750: 365 falsos positivos (ingreso bajo clasificado como alto) y 1.385 falsos negativos (ingreso alto clasificado como bajo). Dicho de otro modo, identificó correctamente al 50,8% de quienes superan el SMMLV y al 92,9% de quienes no lo superan. Predominan los falsos negativos: el modelo tiende a clasificar como «Bajo» a trabajadores que en realidad superan el salario mínimo, un sesgo esperable cuando «Bajo» es la clase mayoritaria y la votación por mayoría la favorece.
Tabla 15. Métricas de evaluación del modelo KNN con el k óptimo
| Métrica | Descripción | Valor |
|---|---|---|
| Exactitud (Accuracy) | Proporción total de predicciones correctas | 0,7791 |
| Intervalo de confianza 95 % | Intervalo de confianza para la exactitud | (0,7698; 0,7882) |
| Tasa de no información | Exactitud al predecir siempre la clase mayoritaria | 0,6449 |
| Valor p [Exactitud > TNI] | Significancia estadística frente a la tasa base | 2,57e-148 |
| Kappa de Cohen | Acuerdo entre lo observado y lo predicho, ajustado por azar | 0,4749 |
| Valor p de McNemar | Asimetría entre falsos positivos y falsos negativos | 4,68e-131 |
| Sensibilidad (Recall) | Capacidad de detectar ingresos altos (VP / VP + FN) | 0,5076 |
| Especificidad | Capacidad de detectar ingresos bajos (VN / VN + FP) | 0,9285 |
| Valor predictivo positivo (Precisión) | Acierto cuando se predice ingreso alto (VP / VP + FP) | 0,7964 |
| Valor predictivo negativo | Acierto cuando se predice ingreso bajo (VN / VN + FN) | 0,7740 |
| Exactitud balanceada | Promedio entre sensibilidad y especificidad | 0,7181 |
| AUC | Capacidad de discriminación sin depender del umbral | 0,8207 |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). VP = verdadero positivo, VN = verdadero negativo, FP = falso positivo, FN = falso negativo. Clase positiva: ingreso alto. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Con k = 59, la exactitud en la muestra de prueba es de 77,9%, con un intervalo de confianza del 95 % entre 77,0% y 78,8%. Para valorarla hay que compararla con la tasa de no información (64,5%), la exactitud que se lograría prediciendo siempre la clase mayoritaria: el valor p indica que el modelo supera de forma estadísticamente significativa esa referencia. La sensibilidad (50,8%) y la especificidad (92,9%) miden el acierto en cada clase por separado, y la precisión o valor predictivo positivo (79,6%) indica qué proporción de los clasificados como «Alto» realmente lo es. El kappa de Cohen (0,475) descuenta los aciertos esperables por azar y corresponde a un acuerdo moderado según la escala de Landis y Koch (1977). Por su parte, la prueba de McNemar indica que los falsos positivos y los falsos negativos no están balanceados. Finalmente, el AUC (0,821) mide la capacidad de discriminar entre ambas clases sin depender del umbral y, según la escala de Hosmer et al. (2013), se califica como excelente.
El modelo Logit estima la probabilidad de que un trabajador tenga
ingreso alto a partir de las nueve variables explicativas, mediante
máxima verosimilitud con el conjunto de entrenamiento (Hosmer et al.,
2013). Su forma es
logit(P(Alto)) = β0 + β1·edad + β2·edad² + β3·antigüedad + β4·horas + (efectos de nivel educativo, tipo de empleo, sexo, zona y estado civil).
Cada coeficiente es el cambio en el logaritmo de los odds de ingreso
alto asociado a un aumento de una unidad (o al cambio de categoría),
manteniendo constantes las demás variables; su exponencial es el
odds ratio (OR): un valor mayor que 1 indica mayor probabilidad
de ingreso alto que la categoría de referencia, y uno menor que 1, menor
probabilidad. La tabla presenta los coeficientes, sus errores estándar,
el estadístico z, el valor p, el odds ratio y su intervalo de confianza
del 95 %.
Tabla 16. Coeficientes y odds ratios del modelo Logit
logit <- glm(fml, data = train, family = binomial)
coef_tab <- summary(logit)$coefficients
ic <- confint.default(logit)
signif_txt <- function(p) {
case_when(p < 0.001 ~ "***", p < 0.01 ~ "**", p < 0.05 ~ "*", p < 0.1 ~ ".", TRUE ~ "")
}
tabla_logit_coef <- tibble(
Término = nombre_term(rownames(coef_tab)),
Coeficiente = round(coef_tab[, 1], 4),
`Error estándar` = round(coef_tab[, 2], 4),
`Estadístico z` = round(coef_tab[, 3], 4),
`p-valor` = if_else(coef_tab[, 4] < 0.0001, "<0,0001",
formatC(coef_tab[, 4], format = "f", digits = 4, decimal.mark = ",")),
Signif. = signif_txt(coef_tab[, 4]),
`Odds ratio` = round(exp(coef_tab[, 1]), 4),
`IC 95% inf` = round(exp(ic[, 1]), 4),
`IC 95% sup` = round(exp(ic[, 2]), 4)
)
tabla(tabla_logit_coef, format.args = list(decimal.mark = ","))| Término | Coeficiente | Error estándar | Estadístico z | p-valor | Signif. | Odds ratio | IC 95% inf | IC 95% sup |
|---|---|---|---|---|---|---|---|---|
| Intercepto | -5,2268 | 0,2384 | -21,9239 | <0,0001 | *** | 0,0054 | 0,0034 | 0,0086 |
| Edad | 0,0970 | 0,0092 | 10,4873 | <0,0001 | *** | 1,1019 | 1,0821 | 1,1220 |
| Edad al cuadrado | -0,0010 | 0,0001 | -9,6260 | <0,0001 | *** | 0,9990 | 0,9988 | 0,9992 |
| Antigüedad (meses) | 0,0018 | 0,0002 | 8,1429 | <0,0001 | *** | 1,0018 | 1,0013 | 1,0022 |
| Horas semanales | 0,0357 | 0,0017 | 20,9110 | <0,0001 | *** | 1,0364 | 1,0329 | 1,0399 |
| Educación: Secundaria | 0,6849 | 0,0641 | 10,6882 | <0,0001 | *** | 1,9835 | 1,7494 | 2,2490 |
| Educación: Técnica/Tecnología | 1,6197 | 0,0760 | 21,3121 | <0,0001 | *** | 5,0515 | 4,3524 | 5,8629 |
| Educación: Universitaria | 2,8002 | 0,0774 | 36,1553 | <0,0001 | *** | 16,4477 | 14,1313 | 19,1439 |
| Educación: Posgrado | 4,6010 | 0,1556 | 29,5781 | <0,0001 | *** | 99,5882 | 73,4173 | 135,0882 |
| Tipo de empleo: Empleado del gobierno | 2,2759 | 0,1487 | 15,3022 | <0,0001 | *** | 9,7367 | 7,2746 | 13,0320 |
| Tipo de empleo: Empleado doméstico | -0,2215 | 0,1209 | -1,8311 | 0,0671 | . | 0,8013 | 0,6322 | 1,0157 |
| Tipo de empleo: Cuenta propia | -0,5818 | 0,0426 | -13,6428 | <0,0001 | *** | 0,5589 | 0,5141 | 0,6076 |
| Tipo de empleo: Patrón o empleador | 1,5063 | 0,1269 | 11,8670 | <0,0001 | *** | 4,5098 | 3,5166 | 5,7837 |
| Tipo de empleo: Otros (familiar sin pago / jornalero) | -0,4352 | 0,1505 | -2,8924 | 0,0038 | ** | 0,6471 | 0,4818 | 0,8691 |
| Sexo: Mujer | -0,4585 | 0,0423 | -10,8521 | <0,0001 | *** | 0,6322 | 0,5820 | 0,6868 |
| Zona: Rural | -0,2316 | 0,0704 | -3,2882 | 0,0010 | ** | 0,7933 | 0,6910 | 0,9107 |
| Estado civil: Unión libre (>=2 años) | -0,2481 | 0,1188 | -2,0883 | 0,0368 |
|
0,7803 | 0,6182 | 0,9849 |
| Estado civil: Casado(a) | 0,0241 | 0,1240 | 0,1946 | 0,8457 | 1,0244 | 0,8033 | 1,3064 | |
| Estado civil: Separado(a) / Divorciado(a) | -0,3832 | 0,1255 | -3,0535 | 0,0023 | ** | 0,6817 | 0,5331 | 0,8718 |
| Estado civil: Viudo(a) | -0,2830 | 0,1962 | -1,4425 | 0,1492 | 0,7535 | 0,5130 | 1,1069 | |
| Estado civil: Soltero(a) | -0,3407 | 0,1186 | -2,8732 | 0,0041 | ** | 0,7113 | 0,5638 | 0,8974 |
Nota: Categorías de referencia: Primaria, Empleado empresa particular, Hombre, Urbano y Unión libre (<2 años). Significancia: *** p<0,001, ** p<0,01, * p<0,05, . p<0,1. Los odds ratios son la exponencial de cada coeficiente. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
De los 20 coeficientes asociados a las variables explicativas, 17 resultaron estadísticamente significativos al 5 %. La deviance residual del modelo (17.238,6) es menor que la deviance nula (24.054,8), lo que refleja el aporte conjunto de las variables explicativas frente a un modelo sin predictores; el AIC es 17.280,6. Estos indicadores describen el ajuste dentro de la muestra de entrenamiento; la capacidad de clasificación se evalúa con el conjunto de prueba. Los coeficientes se interpretan como asociaciones estadísticas, no como relaciones causales.
Nivel educativo. Es la variable con efectos más fuertes. Frente a Primaria (referencia) y con las demás variables constantes, los odds de ingreso alto se multiplican por 1,98 en Secundaria, 5,05 en Técnica/Tecnología, 16,45 en Universitaria y 99,59 en Posgrado: un gradiente creciente que coincide con el patrón descriptivo. El intervalo de confianza de Posgrado es muy amplio (de 73,4 a 135,1) porque el entrenamiento solo contiene 56 posgraduados con ingreso bajo; la magnitud exacta de ese odds ratio es, por tanto, imprecisa, aunque su sentido no lo es.
Tipo de empleo. Tomando como referencia a los empleados de empresa particular, trabajar para el gobierno multiplica los odds de ingreso alto por 9,74 y ser patrón o empleador, por 4,51, mientras que el trabajo por cuenta propia los reduce (OR de 0,56). Para el empleo doméstico el OR es 0,80 (p = 0,067): sin controlar por otras variables, sus odds de ingreso alto eran 0,32 veces los del empleo particular, pero esa desventaja se atenúa cuando se comparan trabajadores con igual educación, horas y demás características. Para la categoría «Otros» el OR es 0,65 (p = 0,004).
Sexo y zona. A igual edad, educación, horas, antigüedad, tipo de empleo, zona y estado civil, los odds de ingreso alto de una mujer son 0,63 veces los de un hombre (p < 0,001). Es una brecha más amplia que la observada sin controles (0,85), lo que sugiere que el mayor nivel educativo de las mujeres de la base enmascara parte de su desventaja en el análisis descriptivo. Vivir en zona rural reduce los odds (OR de 0,79, p = 0,001), aunque menos que sin controles (0,39), lo que indica que buena parte de la brecha rural-urbana se asocia con diferencias de educación y de tipo de ocupación.
Estado civil. La categoría de referencia (unión libre de menos de dos años) es la más pequeña de la base (2,6 %), por lo que las comparaciones son poco precisas: 3 de las 5 categorías contrastadas resultan significativas al 5 %. Aunque los casados destacaban en el análisis descriptivo (50,0 % con ingreso alto), su OR frente a la referencia, una vez controladas las demás variables, es 1,02, menor que el OR sin controles (1,81): buena parte de su ventaja se asocia con ser mayores y tener más antigüedad.
Variables numéricas. Los coeficientes de edad, edad al cuadrado, antigüedad y horas se interpretan por unidad (un año, un mes, una hora), por lo que sus OR individuales están cerca de 1; la Tabla 17 los traduce a cambios representativos.
El Gráfico 18 resume los odds ratios de la Tabla 16 con sus intervalos de confianza del 95 %, en escala logarítmica para que un OR de 2 y uno de 0,5 queden a la misma distancia del valor neutro. La línea punteada marca OR = 1 (ausencia de efecto): los puntos a su derecha aumentan la probabilidad de ingreso alto y los de su izquierda la reducen. Los términos con intervalo que no cruza 1 son significativos al 5 % y se destacan en morado; los demás aparecen en gris. Los niveles educativos y el empleo en el gobierno o como patrón son los más alejados de 1, hacia la derecha; el trabajo por cuenta propia, el sexo femenino y la zona rural quedan a su izquierda.
Gráfico 18. Odds ratios del modelo Logit con intervalos de confianza del 95 %
or_df <- tibble(
Término = nombre_term(rownames(coef_tab)),
OR = exp(coef_tab[, 1]),
inf = exp(ic[, 1]),
sup = exp(ic[, 2]),
p = coef_tab[, 4]
) %>%
filter(Término != "Intercepto") %>%
mutate(
Significancia = if_else(p < 0.05, "p < 0,05", "p ≥ 0,05"),
texto = paste0(
"<b>", Término, "</b>",
"<br>Odds ratio: ", fmt_miles(OR, 3),
"<br>IC 95 %: (", fmt_miles(inf, 3), "; ", fmt_miles(sup, 3), ")",
"<br>Valor p: ", if_else(p < 0.0001, "<0,0001", fmt_miles(p, 4))
)
)
orden_or <- or_df$Término # el primer término queda arriba
# cada categoría de significancia es una traza: así la leyenda muestra su ícono
agrega_or <- function(g, nivel, color) {
d <- dplyr::filter(or_df, Significancia == nivel)
if (nrow(d) == 0) return(g)
plotly::add_trace(
g, data = d, x = ~OR, y = ~Término,
type = "scatter", mode = "markers", name = nivel,
marker = list(color = color, size = 9),
error_x = list(type = "data", symmetric = FALSE,
array = d$sup - d$OR, arrayminus = d$OR - d$inf,
color = color, thickness = 2, width = 0),
text = ~texto, hoverinfo = "text"
)
}
plotly::plot_ly(height = 760) %>%
agrega_or("p < 0,05", pal[["grape"]]) %>%
agrega_or("p ≥ 0,05", "#b9b5c9") %>%
plotly::layout(
xaxis = list(
title = list(text = "Odds ratio (escala logarítmica)", standoff = 15),
type = "log", gridcolor = "#ECE4FB", zeroline = FALSE, automargin = TRUE
),
yaxis = list(
title = "", type = "category", categoryorder = "array",
categoryarray = rev(orden_or), showgrid = FALSE, automargin = TRUE
),
shapes = list(list(
type = "line", x0 = 1, x1 = 1, y0 = 0, y1 = 1, yref = "paper",
line = list(color = pal[["indigo"]], width = 1.5, dash = "dash")
)),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.12,
title = list(text = "")),
margin = list(l = 260, r = 40, t = 30, b = 110),
separators = ",.",
plot_bgcolor = "white", paper_bgcolor = "white",
font = list(family = fuente_plotly, color = "#2c2a4a"),
hoverlabel = list(bgcolor = "#2c2a4a", bordercolor = "#7fdeff",
font = list(family = fuente_plotly, color = "#ffffff"))
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)Nota: La línea punteada (OR = 1) indica ausencia de efecto. Las categorías de referencia son las de la tabla anterior. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Tabla 17. Odds ratios para cambios representativos en las variables explicativas
b <- coef(logit)
or_edad <- function(desde, hasta) {
exp(unname(b["edad"] * (hasta - desde) + b["edad2"] * (hasta^2 - desde^2)))
}
tabla_or_incrementos <- tibble(
Cambio = c("De 30 a 40 años de edad", "De 50 a 60 años de edad",
"+10 horas semanales", "+12 meses de antigüedad",
"Ser mujer (vs. hombre)", "Zona rural (vs. urbana)"),
`Odds ratio` = round(c(
or_edad(30, 40),
or_edad(50, 60),
exp(unname(10 * b["horas"])),
exp(unname(12 * b["antig"])),
exp(unname(b["sexo_fMujer"])),
exp(unname(b["zona_fRural"]))
), 3)
)
tabla(tabla_or_incrementos, format.args = list(decimal.mark = ","))| Cambio | Odds ratio |
|---|---|
| De 30 a 40 años de edad | 1,300 |
| De 50 a 60 años de edad | 0,867 |
| +10 horas semanales | 1,429 |
| +12 meses de antigüedad | 1,021 |
| Ser mujer (vs. hombre) | 0,632 |
| Zona rural (vs. urbana) | 0,793 |
Nota: El efecto de la edad combina los coeficientes de edad y edad al cuadrado, por lo que depende del punto de partida. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Como los odds ratios por unidad son difíciles de apreciar, la Tabla 17 expresa el efecto de cambios representativos. El efecto de la edad no es monótono: al pasar de 30 a 40 años los odds de ingreso alto se multiplican por 1,300, mientras que de 50 a 60 años se multiplican por 0,867, es decir, disminuyen. Esto es consistente con un perfil de ingresos en forma de U invertida (Mincer, 1974), cuyo máximo se ubica alrededor de los 48 años. Diez horas semanales adicionales multiplican los odds por 1,429 y doce meses más de antigüedad, por 1,021. Ser mujer y vivir en zona rural multiplican los odds por 0,632 y 0,793, respectivamente, es decir, los reducen en 36,8% y 20,7%. Conviene recordar que un odds ratio no equivale a un cambio equivalente en la probabilidad: como el ingreso alto es una clase frecuente (35,5%), un OR alejado de 1 sobrestima el cambio relativo en la probabilidad.
# probabilidades en prueba (evaluación) y en entrenamiento (para elegir el umbral)
p_logit <- as.numeric(predict(logit, newdata = test, type = "response"))
p_logit_train <- as.numeric(predict(logit, newdata = train, type = "response"))
roc_logit <- roc(test$y, p_logit, levels = c("Bajo", "Alto"), direction = "<", quiet = TRUE)
auc_logit <- as.numeric(auc(roc_logit))
# umbral de Youden calculado SOLO con el entrenamiento (la prueba no interviene)
roc_logit_train <- roc(train$y, p_logit_train, levels = c("Bajo", "Alto"),
direction = "<", quiet = TRUE)
umbral_youden_logit <- youden(roc_logit_train)
umbral_prev <- mean(train$y == "Alto")
pred_logit_05 <- factor(if_else(p_logit >= 0.5, "Alto", "Bajo"), levels = c("Bajo", "Alto"))La curva ROC se construye variando el umbral de decisión y graficando, para cada valor, la sensibilidad frente a 1 − especificidad (Fawcett, 2006). Cuanto más se acerca la curva a la esquina superior izquierda, mejor discrimina el modelo entre ambas clases; la diagonal punteada corresponde a un clasificador que no discrimina (AUC = 0,5). El AUC del Logit en la muestra de prueba es de 0,834, valor que la escala de Hosmer et al. (2013) califica como excelente. El punto marcado es el umbral de Youden, que maximiza la suma de sensibilidad y especificidad (Youden, 1950); se calculó con el conjunto de entrenamiento (0,366) y, aplicado a la muestra de prueba, produce una sensibilidad de 67,2% y una especificidad de 82,1%. El umbral resulta menor que 0,50 porque «Alto» es la clase minoritaria: para detectarla mejor hay que aceptar clasificar como altos a trabajadores cuya probabilidad predicha es inferior a la mitad.
Gráfico 19. Curva ROC del modelo Logit con el punto del umbral de Youden
roc_l_df <- tibble(
fpr = round(1 - roc_logit$specificities, 3),
tpr = round(roc_logit$sensitivities, 3)
) %>%
distinct() %>%
arrange(fpr, tpr) %>%
mutate(texto = paste0("1 - Especificidad: ", fmt_miles(fpr, 3),
"<br>Sensibilidad: ", fmt_miles(tpr, 3)))
pt_youden_l <- perf_umbral_rapido(umbral_youden_logit, p_logit, ref_es_alto) %>%
transmute(fpr = 1 - Especificidad, tpr = Sensibilidad,
texto = paste0("Umbral de Youden: ", fmt_miles(umbral_youden_logit, 3),
"<br>1 - Especificidad: ", fmt_miles(fpr, 3),
"<br>Sensibilidad: ", fmt_miles(tpr, 3)))
aviso_youden <- list(
x = pt_youden_l$fpr, y = pt_youden_l$tpr, xref = "x", yref = "y",
text = paste0("<b>Umbral de Youden = ", fmt_miles(umbral_youden_logit, 3), "</b>"),
showarrow = TRUE, arrowhead = 2, arrowcolor = pal[["indigo"]],
ax = 75, ay = 55,
bgcolor = "white", bordercolor = pal[["periwinkle"]], borderwidth = 1.5, borderpad = 4,
font = list(color = pal[["indigo"]], size = 12)
)
eje_roc <- function(titulo) {
list(title = list(text = titulo, standoff = 15), range = c(0, 1),
gridcolor = "#ECE4FB", zeroline = FALSE, automargin = TRUE)
}
plotly::plot_ly() %>%
plotly::add_lines(x = c(0, 1), y = c(0, 1), hoverinfo = "skip", showlegend = FALSE,
line = list(color = pal[["mauve"]], dash = "dash", width = 2)) %>%
plotly::add_lines(data = roc_l_df, x = ~fpr, y = ~tpr, name = "Curva ROC del Logit",
line = list(color = col_modelo[["Logit"]], width = 3),
text = ~texto, hoverinfo = "text") %>%
plotly::add_markers(data = pt_youden_l, x = ~fpr, y = ~tpr, name = "Umbral de Youden",
marker = list(color = pal[["indigo"]], size = 11),
text = ~texto, hoverinfo = "text") %>%
plotly::layout(
xaxis = eje_roc("1 - Especificidad (tasa de falsos positivos)"),
yaxis = eje_roc("Sensibilidad (tasa de verdaderos positivos)"),
annotations = list(aviso_youden),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25),
margin = list(l = 95, r = 40, t = 30, b = 110),
separators = ",.",
plot_bgcolor = "white", paper_bgcolor = "white",
font = list(family = fuente_plotly, color = "#2c2a4a"),
hoverlabel = list(bgcolor = "#2c2a4a", bordercolor = "#7fdeff",
font = list(family = fuente_plotly, color = "#ffffff"))
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). AUC del Logit = 0,834. El punto marca el umbral de Youden (0,366), calculado con el conjunto de entrenamiento. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Tabla 18. Matriz de confusión del modelo Logit con umbral de 0,50
| Predicción | Bajo | Alto |
|---|---|---|
| Bajo | 4.660 | 1.213 |
| Alto | 448 | 1.600 |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Los valores de la diagonal son las clasificaciones correctas. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Con el umbral de 0,50, el Logit acertó en 6.260 de los 7.921 trabajadores de la muestra de prueba (4.660 verdaderos negativos y 1.600 verdaderos positivos) y se equivocó en 1.661: 448 falsos positivos y 1.213 falsos negativos. Identificó correctamente al 56,9% de quienes superan el SMMLV y al 91,2% de quienes no lo superan. También en este modelo predominan los falsos negativos, es decir, los trabajadores de ingreso alto que se clasifican como bajo.
Tabla 19. Métricas de evaluación del modelo Logit con umbral de 0,50
| Métrica | Descripción | Valor |
|---|---|---|
| Exactitud (Accuracy) | Proporción total de predicciones correctas | 0,7903 |
| Intervalo de confianza 95 % | Intervalo de confianza para la exactitud | (0,7812; 0,7992) |
| Tasa de no información | Exactitud al predecir siempre la clase mayoritaria | 0,6449 |
| Valor p [Exactitud > TNI] | Significancia estadística frente a la tasa base | 2,88e-175 |
| Kappa de Cohen | Acuerdo entre lo observado y lo predicho, ajustado por azar | 0,5124 |
| Valor p de McNemar | Asimetría entre falsos positivos y falsos negativos | 2,09e-78 |
| Sensibilidad (Recall) | Capacidad de detectar ingresos altos (VP / VP + FN) | 0,5688 |
| Especificidad | Capacidad de detectar ingresos bajos (VN / VN + FP) | 0,9123 |
| Valor predictivo positivo (Precisión) | Acierto cuando se predice ingreso alto (VP / VP + FP) | 0,7812 |
| Valor predictivo negativo | Acierto cuando se predice ingreso bajo (VN / VN + FN) | 0,7935 |
| Exactitud balanceada | Promedio entre sensibilidad y especificidad | 0,7405 |
| AUC | Capacidad de discriminación sin depender del umbral | 0,8338 |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Clase positiva: ingreso alto. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La exactitud del Logit en la muestra de prueba es de 79,0% (intervalo de confianza del 95 % entre 78,1% y 79,9%), frente a una tasa de no información de 64,5%; el valor p indica que el modelo supera de forma estadísticamente significativa esa referencia. La sensibilidad es de 56,9% y la especificidad de 91,2%; la precisión (valor predictivo positivo) es de 78,1% y el valor predictivo negativo, de 79,3%. El kappa de Cohen (0,512) corresponde a un acuerdo moderado (Landis & Koch, 1977), y la prueba de McNemar indica que los falsos positivos y los falsos negativos no están balanceados. El AUC de 0,834 es la medida de discriminación del modelo que no depende del umbral.
Los modelos entregan una probabilidad (Logit) o una proporción de votos (KNN), y la clasificación final depende del umbral a partir del cual se declara «Alto» a un trabajador. El valor de 0,50 es una convención, no una necesidad: como «Alto» es la clase minoritaria, bajar el umbral aumenta la sensibilidad a costa de la especificidad y tiende a reducir la precisión, y subirlo produce el efecto contrario. Para cada modelo se evalúan 80 umbrales entre 0,01 y 0,80 sobre la muestra de prueba, y se marca con una línea punteada el umbral de Youden, calculado con los datos de entrenamiento (en KNN, mediante validación leave-one-out, para que cada observación no sea vecina de sí misma).
# Umbral de Youden de KNN con el entrenamiento, usando validación leave-one-out
# (knn.cv): cada observación se predice sin contarse a sí misma como vecina.
set.seed(2025)
pred_knn_cv <- class::knn.cv(train = X_train, cl = train$y, k = k_opt_class, prob = TRUE)
pw_cv <- as.numeric(attr(pred_knn_cv, "prob"))
p_knn_train <- as.numeric(if_else(as.character(pred_knn_cv) == "Alto", pw_cv, 1 - pw_cv))
roc_knn_train <- roc(train$y, p_knn_train, levels = c("Bajo", "Alto"),
direction = "<", quiet = TRUE)
umbral_youden_knn <- youden(roc_knn_train)
cortes <- seq(0.01, 0.80, length.out = 80)Gráfico 20. Desempeño del modelo Logit según el umbral de decisión
res_logit <- map_dfr(cortes, perf_umbral_rapido, prob = p_logit, ref_alto = ref_es_alto) %>%
pivot_longer(c(Sensibilidad, Exactitud, Precisión, Especificidad),
names_to = "Métrica", values_to = "Valor")
g_umbral_logit <- ggplot(res_logit, aes(Umbral, Valor, color = Métrica)) +
geom_line(linewidth = 1) +
geom_vline(xintercept = umbral_youden_logit, linetype = "dashed", color = pal[["indigo"]]) +
scale_color_manual(values = c(
Sensibilidad = pal[["grape"]], Exactitud = pal[["lake"]],
Precisión = pal[["periwinkle"]], Especificidad = pal[["mauve"]])) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = "Umbral de decisión", y = "Valor de la métrica")
interactivo(g_umbral_logit, leyenda_abajo = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). La línea punteada marca el umbral de Youden, calculado con el conjunto de entrenamiento. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
En el Logit, a medida que sube el umbral la sensibilidad disminuye y la especificidad aumenta, mientras que la precisión tiende a crecer, aunque con más ruido en los umbrales altos, donde quedan pocos trabajadores clasificados como «Alto». La exactitud alcanza su máximo (79,0%) con un umbral de 0,5. El umbral de Youden (0,366) se ubica en la zona en que sensibilidad y especificidad se equilibran, y por eso detecta más trabajadores de ingreso alto que el umbral de 0,50.
Gráfico 21. Desempeño del modelo KNN según el umbral de decisión
res_knn <- map_dfr(cortes, perf_umbral_rapido, prob = p_knn, ref_alto = ref_es_alto) %>%
pivot_longer(c(Sensibilidad, Exactitud, Precisión, Especificidad),
names_to = "Métrica", values_to = "Valor")
g_umbral_knn <- ggplot(res_knn, aes(Umbral, Valor, color = Métrica)) +
geom_line(linewidth = 1) +
geom_vline(xintercept = umbral_youden_knn, linetype = "dashed", color = pal[["indigo"]]) +
scale_color_manual(values = c(
Sensibilidad = pal[["grape"]], Exactitud = pal[["lake"]],
Precisión = pal[["periwinkle"]], Especificidad = pal[["mauve"]])) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = "Umbral de decisión", y = "Valor de la métrica")
interactivo(g_umbral_knn, leyenda_abajo = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). La línea punteada marca el umbral de Youden, calculado con el conjunto de entrenamiento (validación leave-one-out). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Las curvas de KNN son más escalonadas que las del Logit porque su puntuación es una proporción de votos: con k = 59, solo puede tomar 60 valores, y en la muestra de prueba aparecen 213 valores distintos, frente a 7.762 del Logit. El comportamiento general es el mismo: al subir el umbral la sensibilidad cae y la especificidad aumenta. El umbral de Youden de KNN es de 0,366.
La comparación es válida porque ambos modelos se entrenaron con el mismo conjunto de entrenamiento (18.487 observaciones), se evaluaron sobre el mismo conjunto de prueba (7.921 observaciones) y usan las mismas variables explicativas. Ninguno usó el conjunto de prueba para tomar decisiones de ajuste: el k de KNN se eligió por validación cruzada dentro del entrenamiento y los umbrales de Youden se calcularon con el entrenamiento.
La diferencia se calcula como Logit menos KNN: un valor positivo indica ventaja del Logit y uno negativo, ventaja de KNN. Se considera que los modelos están “a la par” en un indicador cuando la diferencia absoluta es menor a 0,005.
Tabla 20. Comparación del desempeño de los modelos Logit y KNN con umbral de 0,50
comp_integral <- bind_rows(
evalua(p_logit, test$y, 0.5, "Logit (umbral 0,50)", auc_logit),
evalua(p_knn, test$y, 0.5, paste0("KNN, k = ", k_opt_class, " (umbral 0,50)"), auc_knn)
)
met_cols <- c("Exactitud", "Sensibilidad", "Especificidad", "Precisión",
"Balanced Accuracy", "Kappa", "AUC")
met_lab <- c("Exactitud", "Sensibilidad", "Especificidad", "Precisión",
"Exactitud balanceada", "Kappa", "AUC")
tabla_comp <- tibble(
Métrica = met_lab,
KNN = unlist(comp_integral[2, met_cols], use.names = FALSE),
Logit = unlist(comp_integral[1, met_cols], use.names = FALSE)
) %>%
mutate(
Diferencia = round(Logit - KNN, 4),
`Mejor desempeño` = case_when(
abs(Logit - KNN) < 0.005 ~ "A la par",
Logit > KNN ~ "Logit",
TRUE ~ "KNN"
)
) %>%
rename(`Diferencia (Logit − KNN)` = Diferencia)
tabla(tabla_comp, format.args = list(decimal.mark = ","))| Métrica | KNN | Logit | Diferencia (Logit − KNN) | Mejor desempeño |
|---|---|---|---|---|
| Exactitud | 0,7792 | 0,7903 | 0,0111 | Logit |
| Sensibilidad | 0,5084 | 0,5688 | 0,0604 | Logit |
| Especificidad | 0,9283 | 0,9123 | -0,0160 | KNN |
| Precisión | 0,7962 | 0,7812 | -0,0150 | KNN |
| Exactitud balanceada | 0,7184 | 0,7405 | 0,0221 | Logit |
| Kappa | 0,4753 | 0,5124 | 0,0371 | Logit |
| AUC | 0,8207 | 0,8338 | 0,0131 | Logit |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Ambos modelos se evalúan con umbral de 0,50. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Con el umbral de 0,50, el Logit presenta mejor desempeño en: Exactitud, Sensibilidad, Exactitud balanceada, Kappa, AUC. KNN lo presenta en: Especificidad, Precisión. Los modelos quedan a la par en: ninguna. Estas diferencias deben leerse junto con el balance entre sensibilidad y especificidad: un modelo con mayor sensibilidad detecta a más trabajadores de ingreso alto, pero se arriesga a más falsos positivos, mientras que uno con mayor especificidad evita esos falsos positivos a costa de dejar escapar a más trabajadores de ingreso alto.
Tabla 21. Desempeño de los modelos con umbral de Youden y con umbral igual a la prevalencia
comp_umbrales <- bind_rows(
evalua(p_logit, test$y, umbral_youden_logit, "Logit (Youden)", auc_logit),
evalua(p_knn, test$y, umbral_youden_knn, "KNN (Youden)", auc_knn),
evalua(p_logit, test$y, umbral_prev, "Logit (prevalencia)", auc_logit),
evalua(p_knn, test$y, umbral_prev, "KNN (prevalencia)", auc_knn)
)
tabla(comp_umbrales, format.args = list(decimal.mark = ","))| Modelo | Exactitud | Sensibilidad | Especificidad | Precisión | Balanced Accuracy | Kappa | AUC |
|---|---|---|---|---|---|---|---|
| Logit (Youden) | 0,7682 | 0,6715 | 0,8215 | 0,6744 | 0,7465 | 0,4935 | 0,8338 |
| KNN (Youden) | 0,7625 | 0,6651 | 0,8162 | 0,6658 | 0,7406 | 0,4814 | 0,8207 |
| Logit (prevalencia) | 0,7657 | 0,6847 | 0,8103 | 0,6653 | 0,7475 | 0,4918 | 0,8338 |
| KNN (prevalencia) | 0,7534 | 0,6811 | 0,7933 | 0,6447 | 0,7372 | 0,4684 | 0,8207 |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Los umbrales de Youden y de prevalencia se calcularon con el conjunto de entrenamiento. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La Tabla 21 recalcula las métricas con dos umbrales alternativos al de 0,50, ambos obtenidos con el conjunto de entrenamiento: el de Youden (Logit: 0,366; KNN: 0,366) y la prevalencia de la clase «Alto» (0,355). Como los tres umbrales son inferiores a 0,50, ambos modelos clasifican a más trabajadores como «Alto» que con la regla estándar: la sensibilidad del Logit pasa de 56,9% a 67,2% con el umbral de Youden, y la de KNN, de 50,8% a 66,5%, a cambio de una menor especificidad. La exactitud balanceada, que promedia sensibilidad y especificidad, pasa en el Logit de 0,7405 a 0,7465 con el umbral de Youden, y en KNN de 0,7184 a 0,7406. Elegir el umbral es, en última instancia, una decisión sobre qué error resulta más costoso: pasar por alto a trabajadores de ingreso alto o clasificar como altos a quienes no lo son.
Un falso positivo es un trabajador de ingreso bajo clasificado como alto, y un falso negativo, uno de ingreso alto clasificado como bajo.
Tabla 22. Aciertos y errores de clasificación de los modelos KNN y Logit con umbral de 0,50
pred_knn_05 <- factor(if_else(p_knn >= 0.5, "Alto", "Bajo"), levels = c("Bajo", "Alto"))
conteos <- function(pred, real) {
c(VP = sum(pred == "Alto" & real == "Alto"),
VN = sum(pred == "Bajo" & real == "Bajo"),
FP = sum(pred == "Alto" & real == "Bajo"),
FN = sum(pred == "Bajo" & real == "Alto"))
}
ck <- conteos(pred_knn_05, test$y)
cl <- conteos(pred_logit_05, test$y)
n_test <- nrow(test)
tabla_ae <- tibble(
Indicador = c("Verdaderos positivos (Alto clasificado como Alto)",
"Verdaderos negativos (Bajo clasificado como Bajo)",
"Falsos positivos (Bajo clasificado como Alto)",
"Falsos negativos (Alto clasificado como Bajo)",
"Total de clasificaciones correctas",
"Total de clasificaciones incorrectas"),
KNN = c(ck[["VP"]], ck[["VN"]], ck[["FP"]], ck[["FN"]],
ck[["VP"]] + ck[["VN"]], ck[["FP"]] + ck[["FN"]]),
Logit = c(cl[["VP"]], cl[["VN"]], cl[["FP"]], cl[["FN"]],
cl[["VP"]] + cl[["VN"]], cl[["FP"]] + cl[["FN"]])
) %>%
mutate(
`% del conjunto de prueba (KNN)` = paste0(fmt_miles(100 * KNN / n_test, 1), "%"),
`% del conjunto de prueba (Logit)` = paste0(fmt_miles(100 * Logit / n_test, 1), "%"),
KNN = fmt_miles(KNN),
Logit = fmt_miles(Logit)
)
tabla(tabla_ae)| Indicador | KNN | Logit | % del conjunto de prueba (KNN) | % del conjunto de prueba (Logit) |
|---|---|---|---|---|
| Verdaderos positivos (Alto clasificado como Alto) | 1.430 | 1.600 | 18,1% | 20,2% |
| Verdaderos negativos (Bajo clasificado como Bajo) | 4.742 | 4.660 | 59,9% | 58,8% |
| Falsos positivos (Bajo clasificado como Alto) | 366 | 448 | 4,6% | 5,7% |
| Falsos negativos (Alto clasificado como Bajo) | 1.383 | 1.213 | 17,5% | 15,3% |
| Total de clasificaciones correctas | 6.172 | 6.260 | 77,9% | 79,0% |
| Total de clasificaciones incorrectas | 1.749 | 1.661 | 22,1% | 21,0% |
Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Clase positiva: ingreso alto. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
KNN comete 1.749 errores en total (366 falsos positivos y 1.383 falsos negativos), mientras que el Logit comete 1.661 (448 falsos positivos y 1.213 falsos negativos). La diferencia relevante no es solo cuántos errores se cometen, sino de qué tipo: el modelo con más falsos negativos deja de identificar a trabajadores que superan el salario mínimo, y el que tiene más falsos positivos clasifica como de ingreso alto a trabajadores que no lo son.
El gráfico presenta las matrices de confusión de ambos modelos como mapas de calor. Cada celda muestra el número de trabajadores y el porcentaje respecto a su clase real, de modo que la diagonal corresponde a la sensibilidad (fila «Alto») y a la especificidad (fila «Bajo»), y el color más intenso indica una mayor proporción. Al estar normalizadas por clase real, las matrices son comparables entre sí a pesar del desbalance de las clases.
Gráfico 22. Matrices de confusión de los modelos Logit y KNN con umbral de 0,50
mc_long <- bind_rows(
as.data.frame(table(Predicho = pred_logit_05, Real = test$y)) %>%
mutate(Modelo = "Logit"),
as.data.frame(table(Predicho = pred_knn_05, Real = test$y)) %>%
mutate(Modelo = paste0("KNN (k = ", k_opt_class, ")"))
) %>%
group_by(Modelo, Real) %>%
mutate(Proporcion = Freq / sum(Freq)) %>%
ungroup() %>%
mutate(
Real = as.character(Real), Predicho = as.character(Predicho),
etiqueta = paste0(fmt_miles(Freq), "<br>(", fmt_miles(100 * Proporcion, 1), "%)"),
tooltip = paste0("Modelo: ", Modelo,
"<br>Clase real: ", Real,
"<br>Clase predicha: ", Predicho,
"<br>Trabajadores: ", fmt_miles(Freq),
"<br>% de la clase real: ", fmt_miles(100 * Proporcion, 1), "%")
)
modelos_mc <- unique(mc_long$Modelo)
d_mc1 <- filter(mc_long, Modelo == modelos_mc[1])
d_mc2 <- filter(mc_long, Modelo == modelos_mc[2])
# números dentro de cada celda (texto estático)
celdas_mc <- function(d, ejex, ejey) {
lapply(seq_len(nrow(d)), function(i) {
list(x = d$Real[i], y = d$Predicho[i], xref = ejex, yref = ejey,
text = d$etiqueta[i], showarrow = FALSE,
font = list(size = 13,
color = if (d$Proporcion[i] > 0.6) "white" else "#2C2A4A"))
})
}
# título de cada panel, centrado sobre su gráfico
titulo_panel <- function(texto, x) {
list(x = x, y = 1.0, xref = "paper", yref = "paper", text = paste0("<b>", texto, "</b>"),
showarrow = FALSE, xanchor = "center", yanchor = "bottom",
font = list(size = 15, color = "#4F518C"))
}
esc_mc <- list(c(0, "#F4EDFF"), c(1, "#907AD6"))
eje_x_mc <- function(dom, ancla) {
list(domain = dom, anchor = ancla, type = "category",
categoryorder = "array", categoryarray = c("Bajo", "Alto"),
title = list(text = "Clase real", standoff = 12), showgrid = FALSE)
}
eje_y_mc <- function(ancla, titulo = "") {
list(domain = c(0, 1), anchor = ancla, type = "category",
categoryorder = "array", categoryarray = c("Bajo", "Alto"),
autorange = "reversed",
title = list(text = titulo, standoff = 12), showgrid = FALSE)
}
plotly::plot_ly() %>%
plotly::add_heatmap(data = d_mc1, x = ~Real, y = ~Predicho, z = ~Proporcion,
xaxis = "x", yaxis = "y", zmin = 0, zmax = 1,
colorscale = esc_mc, xgap = 3, ygap = 3, showscale = FALSE,
text = ~tooltip, hoverinfo = "text") %>%
plotly::add_heatmap(data = d_mc2, x = ~Real, y = ~Predicho, z = ~Proporcion,
xaxis = "x2", yaxis = "y2", zmin = 0, zmax = 1,
colorscale = esc_mc, xgap = 3, ygap = 3,
text = ~tooltip, hoverinfo = "text",
colorbar = list(title = list(text = "% de cada<br>clase real"),
tickformat = ".0%", len = 0.75)) %>%
plotly::layout(
xaxis = eje_x_mc(c(0, 0.44), "y"),
yaxis = eje_y_mc("x", "Clase predicha"),
xaxis2 = eje_x_mc(c(0.54, 0.98), "y2"),
yaxis2 = eje_y_mc("x2"),
annotations = c(
list(titulo_panel(modelos_mc[1], 0.22), titulo_panel(modelos_mc[2], 0.76)),
celdas_mc(d_mc1, "x", "y"),
celdas_mc(d_mc2, "x2", "y2")
),
margin = list(l = 95, r = 110, t = 60, b = 85),
separators = ",.",
plot_bgcolor = "white", paper_bgcolor = "white",
font = list(family = fuente_plotly, color = "#2c2a4a"),
hoverlabel = list(bgcolor = "#2c2a4a", bordercolor = "#7fdeff",
font = list(family = fuente_plotly, color = "#ffffff"))
) %>%
plotly::config(displaylogo = FALSE, responsive = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Cada celda muestra el número de trabajadores y el porcentaje respecto a su clase real. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
La curva ROC permite comparar los modelos sin depender de un umbral: cuanto más se acerque una curva a la esquina superior izquierda, mejor discrimina entre ambas categorías de ingreso. Los marcadores indican el punto de operación de cada modelo con umbral de 0,50.
Gráfico 23. Curvas ROC de los modelos Logit y KNN
lab_logit <- paste0("Logit (AUC = ", num(auc_logit), ")")
lab_knn <- paste0("KNN (AUC = ", num(auc_knn), ")")
roc_df <- bind_rows(
tibble(Modelo = lab_logit, fpr = 1 - roc_logit$specificities, tpr = roc_logit$sensitivities),
tibble(Modelo = lab_knn, fpr = 1 - roc_knn$specificities, tpr = roc_knn$sensitivities)
) %>%
mutate(fpr = round(fpr, 3), tpr = round(tpr, 3)) %>%
distinct() %>%
arrange(Modelo, fpr, tpr) %>%
mutate(texto = paste0(Modelo, "<br>1 - Especificidad: ", fmt_miles(fpr, 3),
"<br>Sensibilidad: ", fmt_miles(tpr, 3)))
pt_op <- tibble(
Modelo = c(lab_logit, lab_knn),
fpr = c(1 - comp_integral$Especificidad[1], 1 - comp_integral$Especificidad[2]),
tpr = c(comp_integral$Sensibilidad[1], comp_integral$Sensibilidad[2])
) %>%
mutate(texto = paste0("Punto de operación (umbral 0,50)<br>", Modelo,
"<br>1 - Especificidad: ", fmt_miles(fpr, 3),
"<br>Sensibilidad: ", fmt_miles(tpr, 3)))
p_roc_comp <- ggplot(roc_df, aes(fpr, tpr, color = Modelo, text = texto)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = pal[["mauve"]]) +
geom_line(aes(group = Modelo), linewidth = 1.1) +
geom_point(data = pt_op, size = 3.5) +
scale_color_manual(values = setNames(c(col_modelo[["Logit"]], col_modelo[["KNN"]]),
c(lab_logit, lab_knn))) +
scale_x_continuous(limits = c(0, 1)) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = "1 - Especificidad (tasa de falsos positivos)",
y = "Sensibilidad (tasa de verdaderos positivos)")
interactivo(p_roc_comp, tooltip = "text", leyenda_abajo = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). La línea punteada representa un clasificador sin capacidad de discriminación; los puntos son la operación de cada modelo con umbral de 0,50. Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
p_delong <- tryCatch(
as.numeric(roc.test(roc_logit, roc_knn, method = "delong", paired = TRUE)$p.value),
error = function(e) NA_real_
)El AUC del Logit (0,8338) y el de KNN (0,8207) difieren en 0,0131. La prueba de DeLong indica que esa diferencia es estadísticamente significativa (valor p = 0). Como la puntuación de KNN es la proporción de votos entre k = 59 vecinos, toma pocos valores distintos y su curva ROC resulta más escalonada que la del Logit, cuyas probabilidades son continuas; es una característica del método y no un defecto del ajuste.
El gráfico de barras compara, para cada métrica, el valor de ambos modelos con el umbral de 0,50; todas las métricas toman valores entre 0 y 1, y un valor más alto indica mejor desempeño. El kappa suele ser menor que la exactitud porque descuenta los aciertos esperables por azar.
Gráfico 24. Comparación de métricas entre KNN y Logit con umbral de 0,50
met_plot <- tabla_comp %>%
select(Métrica, KNN, Logit) %>%
pivot_longer(c(KNN, Logit), names_to = "Modelo", values_to = "Valor") %>%
mutate(
Métrica = factor(Métrica, levels = met_lab),
tooltip = paste0("Modelo: ", Modelo, "<br>Métrica: ", Métrica,
"<br>Valor: ", fmt_miles(Valor, 4))
)
p_met <- ggplot(met_plot, aes(Métrica, Valor, fill = Modelo, text = tooltip)) +
geom_col(position = position_dodge(width = 0.8), width = 0.7) +
scale_fill_manual(values = col_modelo) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = NULL, y = "Valor de la métrica") +
theme(axis.text.x = element_text(angle = 20, hjust = 1))
interactivo(p_met, tooltip = "text", leyenda_abajo = TRUE)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
dif_abs <- abs(tabla_comp$`Diferencia (Logit − KNN)`)
i_max <- which.max(dif_abs)
a_la_par <- tabla_comp$Métrica[tabla_comp$`Mejor desempeño` == "A la par"]
mejor_max <- tabla_comp$`Mejor desempeño`[i_max]
txt_max <- if (mejor_max == "A la par") "que se considera a la par" else paste0("a favor de ", mejor_max)La mayor diferencia entre los modelos se observa en Sensibilidad, con una brecha de 0,0604 (a favor de Logit). Los indicadores en los que ambos modelos quedan a la par (diferencia inferior a 0,005) son: ninguno.
Conviene verificar si los modelos se equivocan en los mismos trabajadores o en distintos. Si fallan en los mismos casos, el límite de desempeño lo imponen las variables y no el método.
Tabla 23. Concordancia entre los aciertos y errores de KNN y Logit
ok_knn <- pred_knn_05 == test$y
ok_logit <- pred_logit_05 == test$y
tabla_conc <- tibble(
`Resultado de la clasificación` = c("Ambos modelos aciertan", "Solo KNN acierta",
"Solo Logit acierta", "Ambos modelos fallan"),
Trabajadores = c(sum(ok_knn & ok_logit), sum(ok_knn & !ok_logit),
sum(!ok_knn & ok_logit), sum(!ok_knn & !ok_logit))
) %>%
mutate(`% del conjunto de prueba` = pct(Trabajadores / sum(Trabajadores)),
Trabajadores = fmt_miles(Trabajadores))
tabla(tabla_conc)| Resultado de la clasificación | Trabajadores | % del conjunto de prueba |
|---|---|---|
| Ambos modelos aciertan | 5.919 | 74,7% |
| Solo KNN acierta | 253 | 3,2% |
| Solo Logit acierta | 341 | 4,3% |
| Ambos modelos fallan | 1.408 | 17,8% |
p_mcnemar <- tryCatch(
as.numeric(mcnemar.test(table(ok_knn, ok_logit))$p.value),
error = function(e) NA_real_
)
mismos <- mean(pred_knn_05 == pred_logit_05)Nota: Métricas calculadas sobre la muestra de prueba (30 % de las observaciones). Fuente: cálculos propios con base en los microdatos de la Gran Encuesta Integrada de Hogares (GEIH), DANE, enero de 2024.
Los dos modelos asignan la misma categoría al 92,5% de los trabajadores del conjunto de prueba. La prueba de McNemar indica que uno de los modelos acierta significativamente más que el otro en los casos discordantes (valor p = 4e-04). Una alta coincidencia indica que la mayor parte de los errores depende de la información contenida en las variables explicativas más que del método elegido.
comp_05 <- comp_integral # fila 1 = Logit, fila 2 = KNN
comp_prev <- comp_umbrales %>% filter(grepl("prevalencia", Modelo))
# "A la par" cuando la diferencia absoluta es menor a 0,005
ganador <- function(v_logit, v_knn, tol = 0.005) {
d <- v_logit - v_knn
if (abs(d) < tol) "A la par" else if (d > 0) "Logit" else "KNN"
}
frase_gan <- function(g) {
if (g == "A la par") "Ninguno se destaca (diferencia menor a 0,005)" else paste0("**", g, "**")
}
g_acc <- ganador(comp_05$Exactitud[1], comp_05$Exactitud[2])
g_sens <- ganador(comp_05$Sensibilidad[1], comp_05$Sensibilidad[2])
g_esp <- ganador(comp_05$Especificidad[1], comp_05$Especificidad[2])
g_auc <- ganador(comp_05$AUC[1], comp_05$AUC[2])
g_sens_prev <- ganador(comp_prev$Sensibilidad[1], comp_prev$Sensibilidad[2])
dif_acc_pp <- abs(comp_05$Exactitud[1] - comp_05$Exactitud[2]) * 100
txt_delong <- if (is.na(p_delong)) {
"no fue posible aplicar la prueba de DeLong"
} else if (p_delong >= 0.05) {
paste0("la prueba de DeLong no encuentra diferencia significativa entre sus AUC (valor p = ", num(p_delong, 4), ")")
} else {
paste0("la prueba de DeLong sí encuentra diferencia significativa entre sus AUC (valor p = ", num(p_delong, 4), ")")
}En desempeño predictivo, la diferencia de exactitud entre ambos modelos es de 1,11 puntos porcentuales y la prueba de DeLong sí encuentra diferencia significativa entre sus AUC (valor p = 0).
Se recomienda el Logit como modelo principal por las siguientes razones, que se mantienen aunque las métricas de ambos modelos sean cercanas:
Si el objetivo fuera únicamente maximizar la detección de quienes superan el mínimo, convendría comparar ambos modelos ajustando el umbral de decisión, ya que la tabla del umbral por prevalencia muestra cómo cambia el balance entre sensibilidad y especificidad.
Este trabajo buscó establecer en qué medida las condiciones laborales y las características sociodemográficas permiten clasificar a los ocupados según superen o no el SMMLV de 2024 ($1.300.000), con microdatos de la GEIH de enero de 2024 (26.408 trabajadores) y dos algoritmos: la Regresión Logística y los K-Vecinos Más Cercanos.
El nivel de ingreso. El ingreso laboral es bajo y muy desigual. La mediana ($1.160.000) está por debajo del SMMLV, la media ($1.643.080) la supera en 41,6% por efecto de una cola derecha extensa, y el 64,5% de los trabajadores de la base quedó en la categoría Bajo.
Los factores asociados al ingreso. El nivel educativo mostró la asociación más fuerte: la proporción de ingreso alto va de 13,7% en Primaria a 94,7% en Posgrado. Le sigue el tipo de empleo, con 94,4% en los empleados del gobierno y 74,51% en los patrones, frente a 23,1% en los trabajadores por cuenta propia y 17,7% en los empleados domésticos. La zona de residencia (37,6% urbano frente a 19,1 % rural) y el sexo (37,1% de los hombres frente a 33,5% de las mujeres) muestran brechas descriptivas más moderadas. En el modelo Logit, con las demás variables controladas, ser mujer se asocia con unos odds de ingreso alto 36,8% menores, una brecha mayor que la descriptiva, mientras que la brecha rural-urbana se atenúa, lo que indica que se explica en buena parte por diferencias de educación y ocupación. La edad sigue un perfil de U invertida con máximo alrededor de los 48 años, y las horas trabajadas y la antigüedad tienen efectos positivos pero más modestos (diez horas semanales adicionales multiplican los odds por 1,43). Todos estos resultados son asociaciones estadísticas, no relaciones causales.
El desempeño predictivo. Las variables disponibles permiten clasificar mejor que la regla trivial de predecir siempre «Bajo», aunque con limitaciones. En la muestra de prueba, el Logit acierta en 79,0% de los casos (AUC de 0,834) y KNN en 77,9% (AUC de 0,821), frente a 64,5% que lograría la regla trivial, es decir, una ventaja de 14,5 puntos porcentuales del Logit. El desempeño es asimétrico: con el umbral de 0,50, la especificidad del Logit (91,2%) es mayor que su sensibilidad (56,9%), de modo que el modelo identifica mejor a los trabajadores de ingreso bajo que a los de ingreso alto. Ese equilibrio puede ajustarse con el umbral de decisión, a cambio de más falsos positivos.
La comparación de modelos. Con las mismas variables, los mismos datos de entrenamiento y la misma muestra de prueba, la prueba de DeLong sí encuentra diferencia significativa entre sus AUC (valor p = 0). La diferencia de exactitud entre ambos modelos es de 1,11 puntos porcentuales. En cualquier caso, el Logit se recomienda como modelo principal no solo por su desempeño, sino porque es interpretable (entrega odds ratios con intervalos de confianza), es eficiente al predecir y es menos sensible a la escala de las variables y a la elección de k; KNN queda como alternativa de referencia.
Sí, en buena medida, pero no de forma plena.
Por qué sí: (1) supera la regla trivial de predecir siempre «Bajo» (diferencia significativa, AUC = 0,834); (2) identifica que variables y en qué magnitud se asocian con superar el SMMLV; (3) permite comparar dos algoritmos bajo las mismas condiciones.
Por qué no del todo: (1) la sensibilidad (56,9%) indica que con 0,50 se pierde a una parte importante de quienes superan el mínimo; (2) un kappa 0,512 muestra que la mejora sobre el azar es moderada, por lo que sirve para análisis agregado y no para decisiones individuales; (3) el umbral de $1.300.000 parte en dos una masa de ingresos concentrada entre $1.160.000 y $1.300.000 (24% de la base), lo que hace frágil la clasificación cerca del corte; (4) faltan variables clave (sector, formalidad, región, tamaño de empresa) y los factores de expansión, así que los resultados describen la muestra y no la población ocupada.
Con base en los hallazgos: (1) Educación y formación: priorizar acceso a educación técnica, tecnológica y superior, donde se concentra el salto en la probabilidad de ingreso alto. (2) Formalización de cuenta propia: son el 41,9% de los ocupados y solo el 23,1% supera el mínimo; programas de formalización, seguridad social y productividad. (3) Brecha rural: inversión en conectividad, educación y empleo no agrícola en zonas rurales. (4) Equidad de género: la brecha persiste (e incluso se amplía) al controlar por educación; transparencia salarial y políticas de corresponsabilidad en el cuidado. (5) Jornadas parciales: el 22,8% de los de ingreso bajo trabaja menos de 40 horas; evaluar subempleo por horas y políticas de ampliación de jornada deseada.
Sobre el modelo: (1) Usar el Logit para análisis explicativo y focalización agregada, ajustando el umbral según el costo del error (Youden si importa detectar ingresos altos). (2) Repetir con el umbral de $1.160.000 como análisis de sensibilidad. (3) Incorporar factores de expansión, formalidad, sector y región; probar árboles, bosques aleatorios o boosting, y validación cruzada repetida en lugar de una sola partición. (4) Modelar el ingreso en varias categorías (múltiplos del SMMLV) o como variable continua.
Limitaciones. (1) Periodo de referencia y umbral. El ingreso de la GEIH corresponde al mes anterior a la entrevista, y 3.132 trabajadores (11,9 %) reportan exactamente $1.160.000, el SMMLV de 2023; otros 3.198 (12,1 %) ganan entre $1.160.001 y $1.300.000. Estos casos quedan clasificados como «Bajo» respecto del salario mínimo de 2024, y la clasificación de los trabajadores cercanos al umbral es intrínsecamente frágil. (2) Muestra, no población. El análisis no usa los factores de expansión de la GEIH, por lo que los resultados describen a los trabajadores de la base y no son estimaciones de la población ocupada del país. (3) Variables omitidas. El ingreso depende de factores que no se incluyeron, como el sector económico, la ocupación, la formalidad, el tamaño de la empresa o la región, lo que limita la capacidad de clasificación. (4) Medición del ingreso. Hay ingresos extremos (el máximo es de $102.000.000) y declaraciones aproximadas en valores redondos; la clasificación binaria reduce su influencia, pero no la elimina de las estadísticas descriptivas. (5) Una sola partición. Los resultados provienen de una única división entrenamiento-prueba con semilla fija; otra división podría alterar ligeramente las métricas. (6) KNN y variables categóricas. El uso de variables indicadoras con distancia euclidiana es una simplificación que puede desfavorecer a KNN. Además, al incluir edad y edad al cuadrado (r = 0,99), la edad pesa doble en el cálculo de las distancias.
Departamento Administrativo Nacional de Estadística [DANE]. (2024). Gran Encuesta Integrada de Hogares (GEIH), enero de 2024 [Microdatos]. https://microdatos.dane.gov.co/index.php/catalog/819/get-microdata
Arango, L. E., Escobar, D. C., & Monsalve, E. (2013). Subempleo por ingresos y funcionamiento del mercado de trabajo en Colombia (Borradores de Economía No. 764). Banco de la República. https://banrep.gov.co/es/borrador-764
Rúa Ramírez, M., & Rivera Duque, E. (2019). Análisis regional de los efectos de la educación y la experiencia en los salarios de Colombia [Trabajo de grado, Universidad EAFIT]. Repositorio Institucional Universidad EAFIT. https://repository.eafit.edu.co/server/api/core/bitstreams/f84ba76f-eebc-431f-9463-86dfb49173fe/content
Cover, T., & Hart, P. (1967). Nearest neighbor pattern classification. IEEE Transactions on Information Theory, 13(1), 21–27.
DeLong, E. R., DeLong, D. M., & Clarke-Pearson, D. L. (1988). Comparing the areas under two or more correlated receiver operating characteristic curves: A nonparametric approach. Biometrics, 44(3), 837–845.
Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition Letters, 27(8), 861–874.
Gobierno de Colombia. (2023). Decreto 2292 del 29 de diciembre de 2023, por el cual se fija el salario mínimo mensual legal.
Hosmer, D. W., Lemeshow, S., & Sturdivant, R. X. (2013). Applied logistic regression (3.ª ed.). Wiley.
James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). An introduction to statistical learning with applications in R (2.ª ed.). Springer.
Kuhn, M. (2008). Building predictive models in R using the caret package. Journal of Statistical Software, 28(5), 1–26.
Landis, J. R., & Koch, G. G. (1977). The measurement of observer agreement for categorical data. Biometrics, 33(1), 159–174.
McNemar, Q. (1947). Note on the sampling error of the difference between correlated proportions or percentages. Psychometrika, 12(2), 153–157.
Mincer, J. (1974). Schooling, experience, and earnings. National Bureau of Economic Research.
R Core Team. (s. f.). R: A language and environment for statistical computing. R Foundation for Statistical Computing. https://www.R-project.org/
Robin, X., Turck, N., Hainard, A., Tiberti, N., Lisacek, F., Sanchez, J.-C., & Müller, M. (2011). pROC: An open-source package for R and S+ to analyze and compare ROC curves. BMC Bioinformatics, 12, 77.
Youden, W. J. (1950). Index for rating diagnostic tests. Cancer, 3(1), 32–35.