Introducción

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.

Metodología

Variable dependiente

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.

Variables independientes

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.

Construcción, limpieza e integracion de la base de datos

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.

Depuración adicional

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")

Base de datos

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.

Resultados Descriptivos

Análisis descriptivo de la variable dependiente

Indicadores estadísticos del ingreso laboral mensual

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.

Análisis gráfico y distribución de la variable dependiente

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_y

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.

# 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_ingreso

Nota: 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.

Análisis descriptivo de las variables independientes

Indicadores estadísticos de las variables independientes cuantitativas

Las variables cuantitativas describen a una fuerza laboral de edad intermedia, con trayectorias laborales muy heterogéneas:

  • Edad: la media es de 41,1 años, la mediana de 40,0 y la desviación estándar de 13,7; los trabajadores tienen entre 18 y 94 años.
  • Edad al cuadrado: es una transformación de la anterior (media de 1.879,5, mediana de 1.600,0, mínimo de 324 y máximo de 8.836, que corresponden a 18² y 94²) y se incluye para capturar los rendimientos decrecientes de la experiencia laboral.
  • Antigüedad laboral: el promedio es de 84,9 meses (unos 7 años), pero la mediana es de 36,0 meses (3 años); la mitad de los ocupados lleva tres años o menos en su empleo actual. El valor máximo es de 768 meses (64 años) y la desviación estándar (110,2) supera a la media, señal de una distribución muy asimétrica.
  • Horas trabajadas: la media es de 45,0 horas semanales y la mediana de 48,0, con valores entre 1 y 100. Que la media sea menor que la mediana indica una cola de jornadas cortas.

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.

Análisis gráfico y distribución de las variables independientes cuantitativas

# 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

histograma_var(datos$edad, "Edad (años)", 2)

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

histograma_var(datos$edad2, "Edad al cuadrado", 200, dec = 0)

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

histograma_var(datos$antig, "Antigüedad en el trabajo actual (meses)", 12)

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

histograma_var(datos$horas, "Horas trabajadas por semana", 5)

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.

Matriz de correlación entre las variables cuantitativas

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.

Análisis bivariado y asociación con la variable dependiente

# 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)
}

Distribución por grupos de edad

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

boxplot_nivel("edad", "Edad (años)", margen_l = 80)

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.

Distribución de la edad al cuadrado

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

boxplot_nivel("edad2", "Edad al cuadrado", margen_l = 80)

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.

Distribución de la antigüedad laboral

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

boxplot_nivel("antig", "Antigüedad laboral (meses)", margen_l = 90)

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.

Distribución de las horas trabajadas semanalmente

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.

Análisis univariado de variables categóricas

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.

Nivel educativo

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

tabla_educ_desc <- resumen_cat_desc(datos$educ_f)
mostrar_cat_desc(tabla_educ_desc)

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.

Tipo de empleado

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

tabla_tipo_desc <- resumen_cat_desc(datos$tipo_emp)
mostrar_cat_desc(tabla_tipo_desc)

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.

Sexo

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

tabla_sexo_desc <- resumen_cat_desc(datos$sexo_f)
mostrar_cat_desc(tabla_sexo_desc)

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.

Zona de residencia

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

tabla_zona_desc <- resumen_cat_desc(datos$zona_f)
mostrar_cat_desc(tabla_zona_desc)

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.

Estado civil

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

tabla_estado_desc <- resumen_cat_desc(datos$est_civil_f)
mostrar_cat_desc(tabla_estado_desc)

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.

Resultados del modelo

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 = ", "))

Partición de los datos

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.

fml <- y ~ edad + edad2 + antig + horas + educ_f + tipo_emp +
  sexo_f + zona_f + est_civil_f
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: ")
}

Modelo KNN

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_knn

Nota: 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

tabla_confusion(pred_knn, test$y)
Valor real
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

tabla(metricas_ext(pred_knn, test$y, auc_knn))
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.

Modelo Logit

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

tabla_confusion(pred_logit_05, test$y)
Valor real
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

tabla(metricas_ext(pred_logit_05, test$y, auc_logit))
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.

Desempeño según el umbral de decisión

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.

Comparación de modelos

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.

Comparación integral de métricas

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.

Aciertos y errores de clasificación

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.

Curvas ROC comparadas

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.

Comparación gráfica de las métricas

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.

Concordancia entre las predicciones de ambos modelos

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.

Síntesis de la comparación

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), ")")
}
  • ¿Cuál tiene mejor exactitud? Logit (Logit: 79,0%; KNN: 77,9%).
  • ¿Cuál detecta mejor los casos positivos (sensibilidad)? Logit con umbral 0,5 (Logit: 56,9%; KNN: 50,8%). Con el umbral ajustado a la prevalencia, Ninguno se destaca (diferencia menor a 0,005) (Logit: 68,5%; KNN: 68,1%).
  • ¿Cuál tiene mayor especificidad? KNN (Logit: 91,2%; KNN: 92,8%).
  • ¿Cuál tiene mejor AUC? Logit (Logit: 0,834; KNN: 0,821).

¿Qué modelo se recomienda?

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:

  1. Interpretabilidad: entrega coeficientes y odds ratios con intervalos de confianza y significancia, lo que permite explicar por qué un trabajador tiene mayor o menor probabilidad de superar el SMMLV. KNN es una “caja negra” sin parámetros interpretables.
  2. Eficiencia: una vez estimado, predecir es instantáneo; KNN debe guardar toda la muestra de entrenamiento y calcular distancias en cada predicción.
  3. Estabilidad: KNN depende de la escala de las variables y de la elección de k, y es sensible a las variables categóricas convertidas en dummies.

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.

Conclusiones y recomendaciones

Conclusiones de los modelos de clasificación

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.

¿El modelo logró el objetivo de la investigación?

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.

Recomendaciones

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.

Bibliografía

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.