1. Introducción

Los ingresos laborales desempeñan un papel fundamental en las condiciones económicas y sociales de las personas, ya que constituyen una de las principales fuentes de recursos para satisfacer necesidades como la alimentación, la vivienda, la educación y el transporte. Además de representar una remuneración por el trabajo realizado, influyen en la capacidad de los hogares para acceder a bienes y servicios, ahorrar y mejorar sus condiciones de vida. Sin embargo, dentro del mercado laboral existen diferencias importantes en los ingresos que reciben los trabajadores, lo que plantea la necesidad de estudiar qué características personales, educativas y laborales pueden estar asociadas con estas diferencias.

Entre los factores que pueden relacionarse con los ingresos laborales se encuentran el sexo, la edad, el nivel educativo, las horas trabajadas y el tipo de empleado. La formación académica puede ampliar las oportunidades de acceder a determinadas ocupaciones, mientras que la edad puede relacionarse con las distintas etapas de la trayectoria laboral. Asimismo, la jornada de trabajo y el tipo de vinculación laboral pueden presentar diferencias en las condiciones de remuneración. Por otra parte, la inclusión del sexo permite examinar posibles diferencias de ingresos entre hombres y mujeres. Estas características pueden interactuar entre sí, por lo que resulta pertinente analizarlas conjuntamente mediante modelos estadísticos.

En este contexto, el aprendizaje supervisado ofrece herramientas para evaluar si las características disponibles de los trabajadores permiten distinguir entre dos categorías de ingreso. En lugar de limitarse a describir los ingresos observados, este enfoque permite construir modelos que aprendan patrones a partir de un conjunto de datos y los utilicen para clasificar nuevas observaciones. De esta manera, es posible evaluar qué tan bien se identifican las categorías de ingreso y comparar el desempeño de diferentes métodos de clasificación.

A partir de esta problemática, se plantea la siguiente pregunta de investigación: ¿qué tan bien permiten las características personales, educativas y laborales clasificar a los trabajadores según tengan un ingreso laboral relativamente bajo o alto?

Para responder esta pregunta, se utilizará una base de datos construida a partir de los microdatos de la Gran Encuesta Integrada de Hogares (GEIH) del Departamento Administrativo Nacional de Estadística (DANE), correspondiente a junio de 2025. Después de aplicar los criterios de selección y preparación de la información, la base analítica contiene 13.885 registros de trabajadores. Se utilizarán cinco variables explicativas: sexo, edad, nivel educativo, horas trabajadas por semana y tipo de empleado. Estas variables permiten considerar características demográficas, educativas y laborales que pueden estar asociadas con la clasificación del ingreso.[1]

La variable dependiente será ingreso_f, construida a partir del ingreso laboral mensual registrado en la base. Para obtener las dos categorías se utiliza la mediana de los ingresos de la muestra, correspondiente a $1.580.000 COP. De esta manera, se clasifica como ingreso bajo a quien presenta un ingreso menor o igual a la mediana y como ingreso alto a quien recibe un ingreso superior. Esta clasificación tiene un carácter relativo a la muestra analizada y no representa un umbral oficial de pobreza ni de suficiencia salarial.

El objetivo principal de la investigación es evaluar y comparar la capacidad de dos técnicas de aprendizaje supervisado para clasificar a los trabajadores según su categoría de ingreso laboral: el método de K vecinos más cercanos (KNN) y la regresión logística (Logit). KNN realiza la clasificación considerando la semejanza entre las observaciones, mientras que Logit estima la probabilidad de pertenecer a la categoría de ingreso alto a partir de las variables explicativas. [9]

El análisis comenzará con la descripción de las variables cuantitativas y categóricas, con el propósito de conocer las características de la muestra y la distribución de las categorías de ingreso. Posteriormente, se construirán y evaluarán ambos modelos utilizando conjuntos de entrenamiento y prueba. Su desempeño se comparará mediante indicadores como la exactitud (accuracy), la sensibilidad, la especificidad, la precisión, el coeficiente Kappa y el área bajo la curva ROC (AUC). Estos indicadores permitirán identificar las fortalezas y limitaciones de cada método y fundamentar la selección del modelo con mejor desempeño para el problema estudiado.

Finalmente, los resultados se interpretarán teniendo en cuenta las características de la base de datos y las limitaciones del análisis. En particular, se reconocerá que una capacidad de clasificación satisfactoria no demuestra que las variables explicativas causen las diferencias de ingreso observadas. El estudio busca evaluar la utilidad de los modelos de aprendizaje supervisado para clasificar los ingresos laborales de los trabajadores incluidos en la muestra y no explicar su evolución a través del tiempo.[1]

2. Metodología

El presente estudio se desarrolla mediante un enfoque cuantitativo basado en técnicas de aprendizaje supervisado, utilizando información sobre los ingresos laborales de trabajadores en Colombia durante junio de 2025. El propósito es analizar si determinadas características personales, educativas y laborales permiten clasificar a los individuos en dos categorías de ingreso: bajo y alto.

Para ello, se emplean dos modelos de clasificación: K vecinos más cercanos (KNN) y regresión logística (Logit). Ambos métodos permiten realizar predicciones a partir de las características de los individuos, aunque utilizan procedimientos diferentes. Posteriormente, sus resultados se comparan mediante indicadores de desempeño para determinar cuál presenta una mayor capacidad de clasificación de los ingresos laborales.

2.1. Definición de las variables

Las variables utilizadas en esta investigación se organizan en dos grupos: una variable dependiente, que representa la categoría de ingreso laboral que se busca clasificar, y cinco variables independientes, correspondientes a características personales, educativas y laborales de los trabajadores.

2.1.1. Variable dependiente: clasificación del ingreso laboral

La variable dependiente, denominada ingreso_f, se construye a partir de la variable ingreso_laboral, que registra el ingreso laboral mensual de los trabajadores. Aunque la base también contiene el logaritmo natural del ingreso (log_ingreso), esta transformación no se utiliza directamente para construir la variable de clasificación en los modelos implementados.

Para convertir el ingreso laboral en una variable categórica binaria, se calcula la mediana de los ingresos de la base analítica, cuyo valor es de $1.580.000 COP. A partir de este punto de corte se establecen las siguientes categorías:

  • Ingresos bajos (Bajo): trabajadores cuyo ingreso laboral mensual es menor o igual a $1.580.000.
  • Ingresos altos (Alto): trabajadores cuyo ingreso laboral mensual es superior a $1.580.000.

En términos de codificación, estas categorías se representan inicialmente mediante los valores 0 y 1, respectivamente, y posteriormente se convierten en un factor con las etiquetas Bajo y Alto. La categoría Alto se define como la clase positiva para evaluar la capacidad de los modelos de identificar correctamente a los trabajadores que pertenecen a este grupo.

La distribución resultante está compuesta por 6.948 observaciones de ingreso bajo y 6.937 de ingreso alto. Esta distribución es prácticamente equilibrada, lo que permite comparar el desempeño de los modelos sin que exista un predominio marcado de una de las dos categorías.

Es importante señalar que la clasificación es relativa a la muestra analizada. Por tanto, el punto de corte no corresponde al salario mínimo legal ni a un umbral oficial de pobreza o suficiencia salarial.

2.1.2. Variables independientes

Para clasificar los ingresos laborales se seleccionaron cinco variables independientes, teniendo en cuenta su relación potencial con las condiciones personales, educativas y laborales de los trabajadores.

Sexo: es una variable categórica que distingue entre hombres y mujeres. Su inclusión permite examinar posibles diferencias en la clasificación del ingreso según el sexo del trabajador.

Edad: es una variable cuantitativa que representa los años cumplidos de cada individuo. Se incorpora para considerar las posibles asociaciones entre la edad, las etapas de la trayectoria laboral y la categoría de ingreso.

Nivel educativo: es una variable categórica que identifica la formación académica alcanzada por el trabajador. Se considera relevante debido a que la educación puede relacionarse con las oportunidades de empleo, los requisitos de las ocupaciones y las condiciones de remuneración.

Horas trabajadas: es una variable cuantitativa que representa la cantidad de horas trabajadas por semana. Permite examinar si la intensidad de la jornada laboral aporta información útil para clasificar el ingreso.

Tipo de empleado: es una variable categórica que distingue entre empleados particulares y empleados del gobierno. Su inclusión permite considerar posibles diferencias en las condiciones laborales y de remuneración entre ambos grupos.

En conjunto, estas cinco variables constituyen los predictores utilizados por los modelos KNN y Logit. La variable ingreso_laboral y su transformación log_ingreso no se incluyen como variables independientes, debido a que el ingreso laboral se utiliza para construir la categoría que se desea predecir.

2.2. Base de datos: origen, filtrado y depuración

2.2.1. Origen de la información

La investigación utiliza una base de datos elaborada previamente en el Taller 1 a partir de los microdatos de la Gran Encuesta Integrada de Hogares (GEIH) del Departamento Administrativo Nacional de Estadística (DANE), correspondientes a junio de 2025. Esta encuesta proporciona información sobre las características sociodemográficas y laborales de la población, así como sobre sus ingresos y condiciones de ocupación.

La fuente institucional puede consultarse en el catálogo de microdatos de la GEIH 2025 del DANE.

El archivo de trabajo base_2.csv contiene 13.885 registros y siete variables originales: sexo, edad, nivel educativo, horas trabajadas, tipo de empleado, ingreso laboral y logaritmo natural del ingreso laboral. La base analítica fue preparada previamente mediante criterios de selección relacionados con la ocupación, la edad, la disponibilidad de información educativa y la existencia de ingresos laborales positivos.

Por consiguiente, la base utilizada en este estudio corresponde a una selección de trabajadores obtenida a partir de la información de la GEIH y no a la totalidad de los registros originales de la encuesta.

2.2.2. Selección y filtrado de variables

Para desarrollar los modelos se seleccionaron las cinco variables independientes descritas anteriormente y la variable dependiente ingreso_f. Esta última se creó a partir del ingreso laboral mensual, utilizando la mediana de $1.580.000 COP como punto de corte.

En la preparación de los datos se conservaron las variables necesarias para la clasificación y se transformaron las variables categóricas en factores de R, con el propósito de facilitar su utilización en los modelos estadísticos. Para el análisis se conservaron los registros originales de la base de trabajo, sin eliminar observaciones por coincidencias entre sus valores.

Aunque el archivo original contiene las variables ingreso_laboral y log_ingreso, ninguna de ellas se utiliza como predictor. Esta decisión evita incorporar directamente en las variables explicativas la información que determina la categoría de ingreso y, de esta forma, reduce el riesgo de fuga de información en la construcción de los modelos.

2.2.3. Revisión y depuración de los datos

La base de datos se importó en RStudio mediante la función read.csv(). Posteriormente, se revisó su estructura y se seleccionaron las variables necesarias para el análisis. También se utilizaron las funciones mutate() y factor() para crear la variable dependiente y establecer el tipo de dato adecuado para las variables categóricas.

En el archivo analizado se verificó que las siete variables originales no presentaban valores faltantes y que los ingresos laborales eran positivos. Por esta razón, la instrucción drop_na() aplicada a las variables del modelo no ocasionó la eliminación de observaciones en la ejecución revisada.

Asimismo, se conservaron las filas coincidentes en sus valores observados, puesto que el archivo de trabajo no contiene identificadores individuales que permitan establecer si corresponden a registros de una misma persona o a trabajadores diferentes con características similares. En consecuencia, no se eliminaron filas repetidas de manera automática.

Finalmente, se construyó la variable binaria de ingreso y se verificó su distribución antes de realizar la división entre entrenamiento y prueba. Esta etapa permitió disponer de una base analítica organizada para la estimación y evaluación de los modelos de clasificación.

2.2.4. Visualización de la base analítica

Con el propósito de presentar la estructura de los datos utilizados en el estudio, se incluye una tabla interactiva que permite consultar las variables de la base analítica. Esta contiene las siete variables originales y dos variables derivadas durante la preparación de la información: ingreso_binario e ingreso_f.

La variable ingreso_binario representa numéricamente la clasificación del ingreso, donde 0 corresponde a la categoría Bajo y 1 a la categoría Alto. Por su parte, ingreso_f presenta las mismas categorías mediante etiquetas de texto y es la variable dependiente utilizada en los modelos de clasificación. Por consiguiente, ambas columnas representan la misma característica y no corresponden a dos variables predictoras diferentes.

La tabla permite consultar las características sociodemográficas y laborales de los trabajadores, sus ingresos mensuales y las variables construidas para el análisis. Se incorporan opciones de búsqueda, filtrado y paginación para facilitar la exploración de los registros.

base_tabla <- read.csv(
  "base_2_con_ingreso_f.csv",
  stringsAsFactors = FALSE,
  check.names = FALSE
)
library(reactable)

reactable(
  base_tabla,
  defaultPageSize = 15,
  showPageSizeOptions = TRUE,
  pageSizeOptions = c(10, 15, 25, 50),
  searchable = TRUE,
  filterable = TRUE,
  striped = TRUE,
  highlight = TRUE,
  bordered = TRUE,
  resizable = TRUE,

  defaultColDef = colDef(
    align = "center",
    minWidth = 125,
    headerStyle = list(
      background = "#171717",
      color = "#E0CCA0",
      fontWeight = "600",
      fontFamily = "DM Sans, Arial, sans-serif",
      borderBottom = "2px solid #C5A46D"
    )
  ),

  style = list(
    fontFamily = "DM Sans, Arial, sans-serif",
    fontSize = "13px",
    background = "#FAF9F6",
    color = "#393939",
    border = "1px solid #E3DFD7",
    borderRadius = "5px",
    boxShadow = "0 8px 24px #0000000D"
  ),

  theme = reactableTheme(
    color = "#393939",
    backgroundColor = "#FFFFFF",
    borderColor = "#E3DFD7",
    borderWidth = "1px",
    stripedColor = "#F0EFEC",
    highlightColor = "#F2F0EB",
    cellPadding = "11px 13px",

    style = list(
      fontFamily = "DM Sans, Arial, sans-serif",
      fontSize = "13px"
    ),

    tableStyle = list(
      borderRadius = "5px",
      overflow = "hidden"
    ),

    headerStyle = list(
      background = "#171717",
      color = "#E0CCA0",
      fontWeight = "600",
      borderBottom = "2px solid #C5A46D"
    ),

    cellStyle = list(
      color = "#333333"
    ),

    inputStyle = list(
      color = "#292929",
      backgroundColor = "#FFFFFF",
      caretColor = "#171717",
      border = "1px solid #C5A46D",
      borderRadius = "3px",
      padding = "6px 9px",
      fontFamily = "DM Sans, Arial, sans-serif",
      fontSize = "12px"
    ),

    filterInputStyle = list(
      color = "#292929",
      backgroundColor = "#FFFFFF",
      caretColor = "#171717",
      border = "1px solid #C5A46D",
      borderRadius = "3px",
      padding = "6px 8px",
      fontFamily = "DM Sans, Arial, sans-serif",
      fontSize = "12px"
    ),

    searchInputStyle = list(
      color = "#292929",
      backgroundColor = "#FFFFFF",
      caretColor = "#171717",
      border = "1px solid #C5A46D",
      borderRadius = "4px",
      padding = "7px 10px",
      fontFamily = "DM Sans, Arial, sans-serif",
      fontSize = "12px"
    ),

    selectStyle = list(
      color = "#292929",
      backgroundColor = "#FFFFFF",
      border = "1px solid #C5A46D",
      borderRadius = "3px",
      fontFamily = "DM Sans, Arial, sans-serif"
    ),

    pageButtonStyle = list(
      backgroundColor = "#171717",
      color = "#E0CCA0",
      border = "1px solid #C5A46D",
      borderRadius = "4px",
      margin = "2px"
    ),

    pageButtonHoverStyle = list(
      backgroundColor = "#242424",
      color = "#FFFFFF",
      borderColor = "#C5A46D"
    ),

    pageButtonActiveStyle = list(
      backgroundColor = "#C5A46D",
      color = "#171717",
      borderColor = "#C5A46D"
    ),

    pageButtonCurrentStyle = list(
      backgroundColor = "#171717",
      color = "#E0CCA0",
      border = "1px solid #C5A46D"
    )
  ),

  columns = list(
    sexo = colDef(
      name = "Sexo"
    ),

    edad = colDef(
      name = "Edad (años)"
    ),

    nivel_educativo = colDef(
      name = "Nivel educativo",
      minWidth = 165
    ),

    horas_trabajadas = colDef(
      name = "Horas trabajadas",
      minWidth = 160
    ),

    tipo_empleado = colDef(
      name = "Tipo de empleado",
      minWidth = 155
    ),

    ingreso_laboral = colDef(
      name = "Ingreso laboral mensual (COP)",
      format = colFormat(
        currency = "COP",
        separators = TRUE,
        locales = "es-CO"
      ),
      style = list(
        color = "#292929",
        fontWeight = "600"
      ),
      minWidth = 190
    ),

    log_ingreso = colDef(
      name = "Logaritmo del ingreso",
      format = colFormat(digits = 2),
      style = list(
        color = "#777777"
      )
    ),

    ingreso_f = colDef(
      name = "Clasificación del ingreso",
      style = function(value) {
        if (value == "Alto") {
          list(
            color = "#8D7041",
            fontWeight = "600"
          )
        } else {
          list(
            color = "#777777"
          )
        }
      },
      minWidth = 190
    )
  )
)

2.3. División de los datos en entrenamiento y prueba

Para evaluar el desempeño de los modelos se dividió la base analítica en dos subconjuntos. El 75 % de las observaciones se destinó al entrenamiento y el 25 % restante se reservó para la prueba.

La partición se realizó mediante la función createDataPartition() del paquete caret, utilizando la semilla aleatoria 28. Este procedimiento permite reproducir la división y mantener aproximadamente las proporciones de las categorías de ingreso en ambos conjuntos.

Como resultado, el conjunto de entrenamiento quedó conformado por 10.414 observaciones, mientras que el conjunto de prueba quedó integrado por 3.471 observaciones. En este último se registraron 1.737 trabajadores de ingreso bajo y 1.734 de ingreso alto.

El conjunto de entrenamiento se utilizó para ajustar los modelos, mientras que el conjunto de prueba permitió comparar las categorías predichas con las categorías observadas. De esta manera, se evaluó la capacidad de clasificación de ambos métodos sobre observaciones que no se utilizaron directamente para estimar sus parámetros.

2.4. Construcción de los modelos de clasificación

Para clasificar a los trabajadores según su categoría de ingreso laboral se utilizaron dos técnicas de aprendizaje supervisado: la regresión logística binaria (Logit) y el método de K vecinos más cercanos (KNN). Ambos modelos emplean las mismas cinco variables predictoras: sexo, edad, nivel educativo, horas trabajadas por semana y tipo de empleado. La variable dependiente es ingreso_f, que distingue entre las categorías Bajo y Alto.

2.4.1. Regresión logística (Logit)

La regresión logística se utilizó para estimar la probabilidad de que un trabajador pertenezca a la categoría de ingreso Alto a partir de sus características personales, educativas y laborales. El modelo se estimó en R mediante la función glm(), con una distribución binomial y utilizando exclusivamente el conjunto de entrenamiento.

Para su estimación, la variable dependiente se codificó numéricamente, asignando el valor 1 a la categoría Alto y el valor 0 a la categoría Bajo. Las variables explicativas fueron el sexo, la edad, el nivel educativo, las horas trabajadas por semana y el tipo de empleado. Para facilitar la interpretación de los coeficientes, se establecieron como categorías de referencia Hombre, Primaria y Particular, respectivamente.

Una vez estimado el modelo, se calcularon las probabilidades de pertenecer a la categoría Alto para las observaciones de entrenamiento y prueba. El umbral de clasificación se seleccionó mediante el índice de Youden, a partir de la curva ROC construida con las probabilidades estimadas en el conjunto de entrenamiento. Posteriormente, el umbral seleccionado se aplicó a las predicciones del conjunto de prueba, sin utilizar este último para elegir el punto de corte. El valor obtenido y sus resultados de evaluación se presentan en la sección 4.3.

Finalmente, se examinaron los coeficientes estimados, sus valores p, las razones de posibilidades (odds ratios) y sus intervalos de confianza aproximados del 95 %. Estos elementos permiten interpretar la asociación entre las características de los trabajadores y la probabilidad estimada de pertenecer a la categoría de ingreso Alto.[8]

2.4.2. K vecinos más cercanos (KNN)

El método K vecinos más cercanos se utilizó para clasificar a los trabajadores según la similitud entre sus características y las observaciones del conjunto de entrenamiento. La clasificación se realizó mediante la función class::knn() del paquete class.

Debido a que el algoritmo requiere variables numéricas para calcular las distancias entre observaciones, las variables categóricas sexo, nivel educativo y tipo de empleado se transformaron en columnas indicadoras mediante la función model.matrix(). Las variables edad y horas trabajadas por semana se conservaron como cuantitativas.

Posteriormente, las variables predictoras se estandarizaron utilizando las medias y desviaciones estándar calculadas a partir del conjunto de entrenamiento. Estos mismos parámetros se aplicaron al conjunto de prueba, con el propósito de mantener una escala comparable entre las observaciones y evitar utilizar directamente los datos de prueba para calcular los parámetros de estandarización.

La selección del número de vecinos se realizó evaluando los valores impares entre 1 y 101 mediante validación cruzada estratificada de cinco particiones dentro del conjunto de entrenamiento. Se seleccionó el valor que maximizó la exactitud promedio obtenida en las particiones. El valor seleccionado y la exactitud correspondiente se presentan en la sección 4.2.

Es decir, En este procedimiento, las columnas indicadoras generadas a partir de las variables categóricas también se centralizaron y estandarizaron, aplicando las medias y desviaciones estándar calculadas con los datos de entrenamiento.

Una vez seleccionado el número de vecinos, el modelo final se aplicó al conjunto de prueba. Además de las categorías predichas por mayoría de votos, se obtuvieron puntuaciones basadas en la proporción de votos a favor de la categoría Alto, que se utilizaron para calcular la curva ROC y el área bajo la curva (AUC).

Los dos modelos se evaluaron utilizando el mismo conjunto de prueba, de manera que sus métricas de desempeño pudieran compararse bajo las mismas condiciones.[4]

2.5. Evaluación y comparación de los modelos

Los dos modelos se evaluaron con las mismas observaciones del conjunto de prueba, estableciendo la categoría Alto como clase positiva. Para comparar su desempeño se utilizaron indicadores que permiten examinar tanto la proporción general de clasificaciones correctas como la capacidad de identificar cada categoría.

La exactitud (accuracy) mide la proporción de observaciones clasificadas correctamente. La sensibilidad indica qué proporción de los trabajadores que realmente pertenecen a la categoría Alto fue identificada correctamente, mientras que la especificidad mide la proporción de trabajadores de ingreso bajo clasificados de forma correcta.

También se calculó la precisión de la categoría Alto, que representa la proporción de predicciones de ingreso alto que realmente correspondían a esa categoría. La exactitud balanceada corresponde al promedio entre sensibilidad y especificidad, mientras que el coeficiente Kappa permite evaluar la concordancia entre las predicciones y las categorías observadas, ajustada por el acuerdo esperado por azar.

Finalmente, se calculó el área bajo la curva ROC (AUC), que permite evaluar la capacidad de discriminación entre las dos categorías para diferentes umbrales de clasificación. Para ello se utilizaron las probabilidades estimadas por el modelo Logit y las puntuaciones derivadas de los votos de los vecinos en KNN.

Las matrices de confusión y las métricas de clasificación se obtuvieron mediante la función confusionMatrix() del paquete caret, mientras que las curvas ROC y los valores AUC se calcularon utilizando las funciones roc() y auc() del paquete pROC.

Los resultados de ambos métodos se reunieron en una tabla comparativa para identificar cuál presentó mayor exactitud, sensibilidad, especificidad y AUC. La selección del modelo más conveniente se fundamentó en el conjunto de indicadores, considerando tanto su desempeño general como su capacidad para detectar los trabajadores pertenecientes a la categoría de ingreso alto.[6]

2.6. Consideraciones y limitaciones metodológicas

La clasificación de los ingresos depende de la mediana observada en la base analítica y, por lo tanto, representa una división relativa a los trabajadores incluidos en ella. Los resultados no deben interpretarse como una clasificación oficial de pobreza o suficiencia salarial.

En la ejecución inicial, el punto de corte se calculó mediante la mediana de los ingresos de la base completa antes de separar los datos en entrenamiento y prueba. Aunque el ingreso laboral no se utiliza directamente como predictor, esta decisión incorpora información de la distribución completa de los ingresos en la definición de la variable dependiente. Por esta razón, constituye una limitación metodológica que debe reconocerse al interpretar la evaluación.

Además, las observaciones corresponden a un periodo específico y el archivo analítico no conserva identificadores que permitan verificar individualmente las filas coincidentes. Por ello, los resultados deben interpretarse con cautela y no generalizarse automáticamente a toda la población colombiana.

Finalmente, la capacidad predictiva de los modelos no implica que las variables explicativas causen las diferencias observadas en los ingresos. El estudio se limita a evaluar la utilidad de KNN y Logit para clasificar las categorías de ingreso laboral a partir de las características disponibles en la base analizada.

3. Análisis Descriptivos

El análisis descriptivo permite conocer las características de los trabajadores incluidos en la base de datos de la Gran Encuesta Integrada de Hogares (GEIH) del DANE, correspondiente a junio de 2025. Para ello, se examinan las distribuciones de las variables, sus principales estadísticas y las relaciones entre las características de los trabajadores y su categoría de ingreso laboral. Este análisis constituye una etapa previa a la construcción de los modelos de clasificación KNN y regresión logística (Logit).[1]

3.1 Análisis descriptivo de la variable dependiente

La Tabla 1 presenta las principales estadísticas descriptivas del ingreso laboral mensual, permitiendo identificar su nivel promedio, valor central y variabilidad en la muestra analizada.

library(dplyr)
library(kableExtra)

tabla_descriptiva <- base_tabla %>%
  summarise(
    `N` = sum(!is.na(ingreso_laboral)),
    `Ingreso promedio` = mean(ingreso_laboral, na.rm=TRUE),
    `Mediana` = median(ingreso_laboral, na.rm=TRUE),
    `Desv. estándar` = sd(ingreso_laboral, na.rm=TRUE),
    `Mínimo` = min(ingreso_laboral, na.rm=TRUE),
    `Máximo` = max(ingreso_laboral, na.rm=TRUE)
  ) %>%
  mutate(
    across(
      -N,
      ~format(
        round(.x, 0),
        big.mark=".",
        decimal.mark=",",
        scientific=FALSE,
        trim=TRUE
      )
    )
  )

tabla_descriptiva %>%
  kbl(
    format="html",
    caption="Tabla 1. Estadísticas descriptivas del ingreso laboral mensual",
    col.names=c(
      "N", "Ingreso promedio", "Mediana",
      "Desv. estándar", "Mínimo", "Máximo"
    ),
    align=c("r","r","r","r","r","r"),
    escape=TRUE
  ) %>%
  kable_styling(
    bootstrap_options=c("hover","condensed"),
    full_width=FALSE,
    font_size=13,
    position="center"
  ) %>%
  row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  row_spec(
    1,
    background="#F0F0F0",
    color="#292929"
  ) %>%
  column_spec(
    1,
    bold=TRUE,
    color="#292929",
    width="1.5cm"
  ) %>%
  column_spec(
    2:6,
    color="#333333",
    width="3cm"
  ) %>%
  footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 1. Estadísticas descriptivas del ingreso laboral mensual
N Ingreso promedio Mediana Desv. estándar Mínimo Máximo
13885 2.214.072 1.580.000 2.163.567 30.000 80.000.000
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.

La variable dependiente del estudio es ingreso_f, construida a partir del ingreso laboral mensual de los trabajadores. Esta variable clasifica las observaciones en dos categorías: Bajo y Alto, utilizando la mediana del ingreso laboral como punto de corte. De esta manera, los ingresos inferiores o iguales a la mediana se clasifican como Bajos, mientras que los superiores se clasifican como Altos.

La distribución obtenida muestra que 6.948 trabajadores (50,04 %) pertenecen a la categoría Bajo y 6.937 (49,96 %) a la categoría Alto. Por tanto, ambas categorías presentan una proporción similar de observaciones. Esta distribución permite trabajar con una variable dependiente binaria sin un desequilibrio marcado entre las clases, lo que facilita la comparación del desempeño de los modelos KNN y Logit.

library(dplyr)
library(DT)
library(htmltools)

tabla_y <- base_tabla %>%
  filter(!is.na(ingreso_f)) %>%
  count(ingreso_f, name="Trabajadores") %>%
  mutate(Porcentaje=100*Trabajadores/sum(Trabajadores))

tabla_y_html <- data.frame(
  "Categoría de ingreso"=as.character(tabla_y$ingreso_f),
  "Trabajadores"=formatC(
    tabla_y$Trabajadores,
    format="d",
    big.mark="."
  ),
  "Porcentaje"=paste0(
    formatC(
      tabla_y$Porcentaje,
      format="f",
      digits=2,
      decimal.mark=","
    ),
    "%"
  ),
  check.names=FALSE
)

htmltools::tagList(
  DT::datatable(
    tabla_y_html,
    rownames=FALSE,
    caption=htmltools::tags$caption(
      style=paste(
        "caption-side: top;",
        "text-align: center;",
        "color: #171717;",
        "font-weight: bold;",
        "font-family: 'Playfair Display', serif;",
        "font-size: 17px;",
        "letter-spacing: 0.2px;",
        "padding: 12px 8px 16px 8px;"
      ),
      "Tabla 2. Distribución de trabajadores según categoría de ingreso"
    ),
    options=list(
      dom="t",
      paging=FALSE,
      searching=FALSE,
      ordering=FALSE,
      autoWidth=TRUE,
      initComplete=DT::JS(
        "function(settings, json) {
          var table = $(this.api().table().node());

          table.css({
            'font-family': 'DM Sans, sans-serif',
            'font-size': '13px',
            'border-collapse': 'separate',
            'border-spacing': '0',
            'border': '1px solid #E0E0E0',
            'border-radius': '6px',
            'overflow': 'hidden',
            'width': '100%'
          });

          table.find('thead th').css({
            'background-color': '#171717',
            'color': '#E0CCA0',
            'font-family': 'DM Sans, sans-serif',
            'font-weight': '600',
            'text-align': 'center',
            'padding': '12px 14px',
            'border-bottom': '2px solid #C5A46D',
            'vertical-align': 'middle'
          });
        }"
      ),
      rowCallback=DT::JS(
        "function(row, data, displayNum, displayIndex, dataIndex) {
          var fondo = (displayIndex % 2 === 0)
            ? '#F0F0F0'
            : '#FFFFFF';

          $('td', row).css({
            'background-color': fondo,
            'padding': '10px 14px',
            'vertical-align': 'middle',
            'border-top': '1px solid #E8E8E8'
          });

          $('td:eq(0)', row).css({
            'text-align': 'left',
            'font-weight': '600',
            'color': '#292929'
          });

          $('td:eq(1), td:eq(2)', row).css({
            'text-align': 'right',
            'color': '#444444',
            'font-variant-numeric': 'tabular-nums'
          });
        }"
      )
    ),
    class="stripe compact",
    width="100%"
  ),
  htmltools::tags$div(
    style=paste(
      "font-family: 'DM Sans', sans-serif;",
      "font-size: 11px;",
      "color: #666666;",
      "margin-top: 8px;",
      "text-align: left;"
    ),
    htmltools::tags$span(
      style="font-weight: 600; color: #171717;",
      "Fuente: "
    ),
    "Elaboración propia con base en la GEIH del DANE, junio de 2025."
  )
)
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.
library(dplyr)
library(plotly)
library(scales)

col_bajo <- "#858585"
col_alto <- "#C5A46D"

datos_y <- base_tabla %>%
  filter(!is.na(ingreso_f)) %>%
  count(ingreso_f, name="Trabajadores") %>%
  mutate(
    Porcentaje=100*Trabajadores/sum(Trabajadores),
    Etiqueta=paste0(
      formatC(Porcentaje, format="f", digits=2, decimal.mark=","),
      "%"
    ),
    Color=if_else(as.character(ingreso_f)=="Bajo", col_bajo, col_alto),
    Detalle=paste0(
      "<b>Categoría de ingreso: ", ingreso_f, "</b>",
      "<br>Trabajadores: ", scales::number(Trabajadores, big.mark="."),
      "<br>Participación: ", Etiqueta,
      "<extra></extra>"
    )
  )

grafico_y <- plot_ly(
  data=datos_y,
  x=~ingreso_f,
  y=~Porcentaje,
  type="bar",
  marker=list(
    color=datos_y$Color,
    line=list(color="#FFFFFF", width=1.5)
  ),
  text=~Etiqueta,
  textposition="outside",
  textfont=list(size=13, color="#171717", family="Source Serif Pro"),
  cliponaxis=FALSE,
  hovertemplate=~Detalle,
  showlegend=FALSE
) %>%
  layout(
    title=list(
      text="<b>Figura 1. Distribución de trabajadores según categoría de ingreso</b><br><sup>GEIH, DANE — junio de 2025</sup>",
      x=0.05,
      xanchor="left",
      font=list(size=17, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title="",
      tickfont=list(size=12, color="#292929", family="Source Serif Pro"),
      showline=FALSE,
      categoryorder="array",
      categoryarray=c("Bajo", "Alto")
    ),
    yaxis=list(
      title=list(
        text="Porcentaje de trabajadores",
        font=list(size=12, color="#171717")
      ),
      ticksuffix="%",
      range=c(0, max(datos_y$Porcentaje)*1.2),
      gridcolor="#E0E0E0",
      zeroline=FALSE
    ),
    bargap=0.5,
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=65, r=30, t=80, b=55),
    hoverlabel=list(
      bgcolor="white",
      bordercolor="#C5A46D",
      font=list(size=11, color="#171717", family="Source Serif Pro")
    ),
    annotations=list(
      list(
        x=1, y=-0.2,
        xref="paper", yref="paper",
        xanchor="right",
        text="Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        showarrow=FALSE,
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(
    displaylogo=FALSE,
    responsive=TRUE,
    modeBarButtonsToRemove=c("pan2d", "lasso2d", "select2d")
  )

grafico_y

3.2 Estadísticas descriptivas de las variables principales

3.2.1 Estadísticas descriptivas de las variables numéricas

La Tabla 3 presenta las principales estadísticas descriptivas de la edad, las horas trabajadas por semana y el ingreso laboral mensual de los trabajadores analizados.

En cuanto a la edad, se observa un promedio de 38,35 años y una mediana de 37 años, con una desviación estándar de 12,38 años. Las edades oscilan entre los 18 y los 92 años, lo que evidencia la presencia de trabajadores de diferentes grupos etarios dentro de la muestra.

Respecto a las horas trabajadas, el promedio es de 46,26 horas semanales, mientras que la mediana corresponde a 47 horas. La desviación estándar es de 9,20 horas, con valores que van desde 2 hasta 130 horas por semana. Aunque las medidas centrales muestran una cantidad de horas trabajadas cercana a las 46 o 47 semanales, el rango observado refleja diferencias importantes entre los trabajadores y la presencia de valores extremos que conviene revisar.

Por su parte, el ingreso laboral mensual presenta un promedio de $2.214.072 y una mediana de $1.580.000. La desviación estándar es de $2.163.566,54, mientras que los ingresos registrados oscilan entre $30.000 y $80.000.000 mensuales. La diferencia entre la media y la mediana, junto con la amplitud del rango, sugiere una distribución con asimetría hacia los ingresos más altos. Esto indica que algunos ingresos elevados pueden incrementar el promedio por encima del valor central de la distribución.

En conjunto, estos resultados muestran diferencias en las características laborales y económicas de los trabajadores. En particular, la variabilidad del ingreso resulta relevante para el estudio, dado que esta variable se utilizó para construir las categorías de ingreso Bajo y Alto que serán clasificadas mediante los modelos KNN y regresión logística.

library(dplyr)
library(kableExtra)

variables <- c("edad", "horas_trabajadas", "ingreso_laboral")

tabla_cuantitativas <- data.frame(
  Variable=c(
    "Edad (años)",
    "Horas trabajadas por semana",
    "Ingreso laboral mensual (COP)"
  ),
  N=sapply(base_tabla[variables], function(x) sum(!is.na(x))),
  Media=sapply(base_tabla[variables], mean, na.rm=TRUE),
  Mediana=sapply(base_tabla[variables], median, na.rm=TRUE),
  `Desv. estándar`=sapply(base_tabla[variables], sd, na.rm=TRUE),
  Mínimo=sapply(base_tabla[variables], min, na.rm=TRUE),
  Máximo=sapply(base_tabla[variables], max, na.rm=TRUE),
  check.names=FALSE
)

tabla_cuantitativas$N <- format(
  tabla_cuantitativas$N,
  big.mark=".",
  scientific=FALSE,
  trim=TRUE
)

tabla_cuantitativas[3:7] <- lapply(
  tabla_cuantitativas[3:7],
  function(x) format(
    round(x, 2),
    big.mark=".",
    decimal.mark=",",
    scientific=FALSE,
    nsmall=2,
    trim=TRUE
  )
)

tabla_cuantitativas %>%
  kbl(
    format="html",
    caption="Tabla 3. Estadísticas descriptivas de las variables cuantitativas",
    col.names=c(
      "Variable", "N", "Media", "Mediana",
      "Desv. estándar", "Mínimo", "Máximo"
    ),
    align=c("l","r","r","r","r","r","r"),
    escape=TRUE
  ) %>%
  kable_styling(
    bootstrap_options=c("hover","condensed"),
    full_width=FALSE,
    font_size=13,
    position="center"
  ) %>%
  row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  column_spec(
    1,
    bold=TRUE,
    width="4cm",
    extra_css="color:#292929;"
  ) %>%
  column_spec(2:7, width="2.2cm") %>%
  footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 3. Estadísticas descriptivas de las variables cuantitativas
Variable N Media Mediana Desv. estándar Mínimo Máximo
edad Edad (años) 13.885 38,35 37,00 12,38 18,00 92,00
horas_trabajadas Horas trabajadas por semana 13.885 46,26 47,00 9,20 2,00 130,00
ingreso_laboral Ingreso laboral mensual (COP) 13.885 2.214.072,13 1.580.000,00 2.163.566,54 30.000,00 80.000.000,00
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.

3.2.2 Distribución de frecuencias de las variables categóricas

La Tabla 4 presenta la distribución de los trabajadores según sexo, nivel educativo y tipo de empleado.

En relación con el sexo, los hombres representan el 53,41 % de la muestra, con 7.416 trabajadores, mientras que las mujeres corresponden al 46,59 %, con 6.469 trabajadoras. Se observa una distribución relativamente equilibrada, aunque existe una mayor participación de hombres.

En cuanto al nivel educativo, la categoría con mayor participación es secundaria, con 4.962 trabajadores, equivalentes al 35,74 % de la muestra. Le siguen la educación universitaria, con el 22,05 %, y la formación técnica o tecnológica, con el 18,99 %. Por su parte, el 14,77 % corresponde a trabajadores con nivel de primaria y el 8,46 % a quienes tienen formación de posgrado. Esta distribución muestra que la educación secundaria constituye la categoría más frecuente dentro de la población analizada.

Finalmente, respecto al tipo de empleado, se observa una concentración en la categoría particular, que reúne 12.328 trabajadores y representa el 88,79 % de la muestra. En contraste, los empleados del gobierno corresponden a 1.557 trabajadores, equivalentes al 11,21 %. Por tanto, la muestra está compuesta principalmente por empleados particulares.

Estas características permiten describir la composición de la población analizada y constituyen un punto de partida para examinar si la categoría de ingreso laboral presenta diferencias según el sexo, el nivel educativo y el tipo de empleado.

library(dplyr)
library(kableExtra)

tabla_categoricas <- bind_rows(
  base_tabla %>%
    count(sexo, name="Frecuencia") %>%
    transmute(
      Variable="Sexo",
      Categoria=as.character(sexo),
      Frecuencia
    ),
  base_tabla %>%
    count(nivel_educativo, name="Frecuencia") %>%
    transmute(
      Variable="Nivel educativo",
      Categoria=as.character(nivel_educativo),
      Frecuencia
    ),
  base_tabla %>%
    count(tipo_empleado, name="Frecuencia") %>%
    transmute(
      Variable="Tipo de empleado",
      Categoria=as.character(tipo_empleado),
      Frecuencia
    ),
  base_tabla %>%
    count(ingreso_f, name="Frecuencia") %>%
    transmute(
      Variable="ingreso_f",
      Categoria=as.character(ingreso_f),
      Frecuencia
    )
) %>%
  group_by(Variable) %>%
  mutate(
    Porcentaje=100*Frecuencia/sum(Frecuencia)
  ) %>%
  ungroup()

tabla_categoricas$Frecuencia <- format(
  tabla_categoricas$Frecuencia,
  big.mark=".",
  scientific=FALSE,
  trim=TRUE
)

tabla_categoricas$Porcentaje <- format(
  round(tabla_categoricas$Porcentaje, 2),
  big.mark=".",
  decimal.mark=",",
  nsmall=2,
  trim=TRUE
)

tabla_categoricas %>%
  kbl(
    format="html",
    caption="Tabla 4. Distribución de frecuencias de las variables categóricas",
    col.names=c(
      "Variable", "Categoría", "Frecuencia", "Porcentaje (%)"
    ),
    align=c("l","l","r","r"),
    escape=TRUE
  ) %>%
  kable_styling(
    bootstrap_options=c("hover","condensed"),
    full_width=FALSE,
    font_size=13,
    position="center"
  ) %>%
  row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  column_spec(
    1,
    bold=TRUE,
    width="4cm",
    extra_css="color:#292929;"
  ) %>%
  column_spec(2, width="4cm") %>%
  column_spec(3:4, width="2.5cm") %>%
  footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 4. Distribución de frecuencias de las variables categóricas
Variable Categoría Frecuencia Porcentaje (%)
Sexo Hombre 7.416 53,41
Sexo Mujer 6.469 46,59
Nivel educativo Postgrado 1.174 8,46
Nivel educativo Primaria 2.051 14,77
Nivel educativo Secundaria 4.962 35,74
Nivel educativo Técnica/Tecnología 2.637 18,99
Nivel educativo Universitaria 3.061 22,05
Tipo de empleado Gobierno 1.557 11,21
Tipo de empleado Particular 12.328 88,79
ingreso_f Alto 6.937 49,96
ingreso_f Bajo 6.948 50,04
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.

3.3 Distribución individual de variables numéricas

Con el propósito de complementar las estadísticas descriptivas, se analiza la distribución de las variables cuantitativas mediante histogramas. Estas representaciones permiten observar la concentración de las observaciones, la dispersión de los datos y la posible presencia de valores extremos.

3.3.1 Distribución del ingreso laboral mensual

El histograma del ingreso laboral mensual permite examinar la dispersión de los ingresos de los trabajadores. La media es de $2.214.072,13, mientras que la mediana es de $1.580.000. La diferencia entre ambas medidas, junto con el máximo registrado de $80.000.000, sugiere una posible asimetría hacia la derecha, asociada a la presencia de ingresos elevados que incrementan el promedio. Esta característica es relevante porque el ingreso laboral se utilizó para construir la variable dependiente ingreso_f, que clasifica a los trabajadores en las categorías Bajo y Alto.

library(dplyr)
library(plotly)
library(scales)

ingreso <- na.omit(base_tabla$ingreso_laboral)
ingreso <- ingreso[is.finite(ingreso) & ingreso >= 0]

n <- length(ingreso)
media_ingreso <- mean(ingreso)
mediana_ingreso <- median(ingreso)

binwidth <- max(100000, ceiling(max(ingreso)/35/100000)*100000)
ingreso_max <- ceiling(max(ingreso)/binwidth)*binwidth
breaks <- seq(0, ingreso_max, by=binwidth)

densidad_ingreso <- density(ingreso, from=0, to=ingreso_max)
frecuencia_densidad <- densidad_ingreso$y*n*binwidth
frecuencias <- hist(ingreso, breaks=breaks, plot=FALSE)$counts
max_y <- max(c(frecuencias, frecuencia_densidad), na.rm=TRUE)*1.2

p_ingreso <- plot_ly(
  data=data.frame(ingreso=ingreso),
  x=~ingreso
) %>%
  add_histogram(
    name="Frecuencia",
    xbins=list(start=0, end=ingreso_max, size=binwidth),
    marker=list(
      color="#858585",
      line=list(color="#FFFFFF", width=1)
    ),
    opacity=0.85,
    hovertemplate="Ingreso mensual: $%{x:,.0f}<br>Frecuencia: %{y}<extra></extra>"
  ) %>%
  add_lines(
    x=densidad_ingreso$x,
    y=frecuencia_densidad,
    name="Densidad",
    line=list(color="#C5A46D", width=2),
    fill="tozeroy",
    fillcolor="rgba(197,164,109,0.15)",
    hovertemplate="Ingreso: $%{x:,.0f}<br>Frecuencia estimada: %{y:.1f}<extra></extra>"
  ) %>%
  layout(
    title=list(
      text="<b> Figura 2. Distribución del ingreso laboral mensual</b>",
      font=list(family="Playfair Display", size=18, color="#171717"),
      x=0.05
    ),
    xaxis=list(
      title=list(text="Ingreso laboral mensual (COP)", font=list(size=12, color="#171717")),
      tickprefix="$",
      tickformat=",.0f",
      gridcolor="#E0E0E0",
      zeroline=FALSE,
      range=c(0, ingreso_max)
    ),
    yaxis=list(
      title=list(text="Frecuencia", font=list(size=12, color="#171717")),
      gridcolor="#E0E0E0",
      rangemode="tozero",
      range=c(0, max_y)
    ),
    shapes=list(
      list(
        type="line",
        x0=mediana_ingreso, x1=mediana_ingreso,
        y0=0, y1=max_y*0.95,
        line=list(color="#C5A46D", dash="dash", width=2)
      ),
      list(
        type="line",
        x0=media_ingreso, x1=media_ingreso,
        y0=0, y1=max_y*0.95,
        line=list(color="#171717", width=2)
      )
    ),
    annotations=list(
      list(
        x=mediana_ingreso, y=max_y*0.94,
        text=paste("Mediana: $", format(round(mediana_ingreso), big.mark=".", scientific=FALSE), sep=""),
        showarrow=FALSE,
        font=list(size=10, color="#A78342"),
        bgcolor="white",
        bordercolor="#C5A46D",
        borderpad=3
      ),
      list(
        x=media_ingreso, y=max_y*0.82,
        text=paste("Media: $", format(round(media_ingreso), big.mark=".", scientific=FALSE), sep=""),
        showarrow=FALSE,
        font=list(size=10, color="#171717"),
        bgcolor="white",
        bordercolor="#171717",
        borderpad=3
      ),
      list(
        x=1, y=-0.25,
        xref="paper", yref="paper",
        xanchor="right",
        text=paste(
          "Fuente: GEIH, DANE, junio de 2025 | N =",
          scales::comma(n), "trabajadores"
        ),
        showarrow=FALSE,
        font=list(family="Source Serif Pro", size=9, color="#858585")
      )
    ),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=65, r=25, t=65, b=75),
    hoverlabel=list(font=list(family="Source Serif Pro")),
    legend=list(
      orientation="h", x=0.5, y=-0.18, xanchor="center",
      font=list(size=10)
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d", "lasso2d", "select2d"),
    displaylogo=FALSE
  )

p_ingreso

3.3.2 Distribución de la edad de los trabajadores

El histograma de la edad permite examinar cómo se distribuyen los trabajadores según sus años de vida. De acuerdo con las estadísticas descriptivas, la edad promedio es de 38,35 años y la mediana es de 37 años, mientras que las edades registradas oscilan entre los 18 y los 92 años. La cercanía entre la media y la mediana sugiere que las medidas centrales presentan valores similares, aunque la forma exacta de la distribución debe determinarse a partir del histograma.

library(dplyr)
library(plotly)
library(scales)

edad <- na.omit(base_tabla$edad)
n <- length(edad)
media_edad <- mean(edad)
mediana_edad <- median(edad)
edad_min <- floor(min(edad)/5)*5
edad_max <- ceiling(max(edad)/5)*5 + 5

densidad_edad <- density(edad, from=edad_min, to=edad_max)
frecuencia_densidad <- densidad_edad$y*n*5
frecuencias <- hist(edad, breaks=seq(edad_min, edad_max, by=5), plot=FALSE)$counts
max_y <- max(c(frecuencias, frecuencia_densidad), na.rm=TRUE)*1.2

p_edad <- plot_ly(
  data=data.frame(edad=edad),
  x=~edad
) %>%
  add_histogram(
    name="Frecuencia",
    xbins=list(start=edad_min, end=edad_max, size=5),
    marker=list(
      color="#858585",
      line=list(color="#FFFFFF", width=1)
    ),
    opacity=0.85,
    hovertemplate="Edad: %{x:.0f} años<br>Frecuencia: %{y}<extra></extra>"
  ) %>%
  add_lines(
    x=densidad_edad$x,
    y=frecuencia_densidad,
    name="Densidad",
    line=list(color="#C5A46D", width=2),
    fill="tozeroy",
    fillcolor="rgba(197,164,109,0.15)",
    hovertemplate="Edad: %{x:.1f} años<br>Frecuencia estimada: %{y:.1f}<extra></extra>"
  ) %>%
  layout(
    title=list(
      text="<b> Figura 3. Distribución de la edad de los trabajadores</b>",
      font=list(family="Playfair Display", size=18, color="#171717"),
      x=0.05
    ),
    xaxis=list(
      title=list(text="Edad (años)", font=list(size=12, color="#171717")),
      dtick=5,
      gridcolor="#E0E0E0",
      zeroline=FALSE,
      range=c(edad_min, edad_max)
    ),
    yaxis=list(
      title=list(text="Frecuencia", font=list(size=12, color="#171717")),
      gridcolor="#E0E0E0",
      zerolinecolor="#C5A46D",
      rangemode="tozero"
    ),
    shapes=list(
      list(
        type="line",
        x0=mediana_edad, x1=mediana_edad,
        y0=0, y1=max_y,
        line=list(color="#C5A46D", dash="dash", width=2)
      ),
      list(
        type="line",
        x0=media_edad, x1=media_edad,
        y0=0, y1=max_y,
        line=list(color="#171717", width=2)
      )
    ),
    annotations=list(
      list(
        x=mediana_edad, y=max_y*0.94,
        text=paste("Mediana:", round(mediana_edad, 1)),
        showarrow=FALSE,
        font=list(size=10, color="#A78342"),
        bgcolor="white",
        bordercolor="#C5A46D",
        borderpad=3
      ),
      list(
        x=media_edad, y=max_y*0.82,
        text=paste("Media:", round(media_edad, 1)),
        showarrow=FALSE,
        font=list(size=10, color="#171717"),
        bgcolor="white",
        bordercolor="#171717",
        borderpad=3
      ),
      list(
        x=1, y=-0.23,
        xref="paper", yref="paper",
        xanchor="right",
        text=paste(
          "Fuente: GEIH, DANE, junio de 2025 | N =",
          scales::comma(n), "trabajadores"
        ),
        showarrow=FALSE,
        font=list(family="Source Serif Pro", size=9, color="#858585")
      )
    ),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=60, r=25, t=65, b=65),
    hoverlabel=list(font=list(family="Source Serif Pro")),
    legend=list(
      orientation="h", x=0.5, y=-0.14, xanchor="center",
      font=list(size=10)
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d", "lasso2d", "select2d"),
    displaylogo=FALSE
  )

p_edad

3.3.3 Distribución de las horas trabajadas por semana

El histograma de las horas trabajadas permite identificar los intervalos semanales en los que se concentra una mayor cantidad de trabajadores. La media es de 46,26 horas y la mediana es de 47 horas semanales. Sin embargo, los valores varían entre 2 y 130 horas, por lo que resulta importante observar la distribución para identificar posibles valores extremos y determinar cómo se concentra la mayor parte de las observaciones.

library(dplyr)
library(plotly)
library(scales)

horas <- na.omit(base_tabla$horas_trabajadas)
n <- length(horas)
media_horas <- mean(horas)
mediana_horas <- median(horas)
horas_min <- floor(min(horas)/5)*5
horas_max <- ceiling(max(horas)/5)*5 + 5

densidad_horas <- density(horas, from=horas_min, to=horas_max)
frecuencia_densidad <- densidad_horas$y*n*5
frecuencias <- hist(horas, breaks=seq(horas_min, horas_max, by=5), plot=FALSE)$counts
max_y <- max(c(frecuencias, frecuencia_densidad), na.rm=TRUE)*1.2

p_horas <- plot_ly(
  data=data.frame(horas=horas),
  x=~horas
) %>%
  add_histogram(
    name="Frecuencia",
    xbins=list(start=horas_min, end=horas_max, size=5),
    marker=list(
      color="#858585",
      line=list(color="#FFFFFF", width=1)
    ),
    opacity=0.85,
    hovertemplate="Horas semanales: %{x:.0f}<br>Frecuencia: %{y}<extra></extra>"
  ) %>%
  add_lines(
    x=densidad_horas$x,
    y=frecuencia_densidad,
    name="Densidad",
    line=list(color="#C5A46D", width=2),
    fill="tozeroy",
    fillcolor="rgba(197,164,109,0.15)",
    hovertemplate="Horas: %{x:.1f}<br>Frecuencia estimada: %{y:.1f}<extra></extra>"
  ) %>%
  layout(
    title=list(
      text="<b>Figura 4. Distribución de las horas trabajadas por semana</b>",
      font=list(family="Playfair Display", size=18, color="#171717"),
      x=0.05
    ),
    xaxis=list(
      title=list(text="Horas trabajadas por semana", font=list(size=12, color="#171717")),
      dtick=5,
      gridcolor="#E0E0E0",
      zeroline=FALSE,
      range=c(horas_min, horas_max)
    ),
    yaxis=list(
      title=list(text="Frecuencia", font=list(size=12, color="#171717")),
      gridcolor="#E0E0E0",
      zerolinecolor="#C5A46D",
      rangemode="tozero"
    ),
    shapes=list(
      list(
        type="line",
        x0=mediana_horas, x1=mediana_horas,
        y0=0, y1=max_y,
        line=list(color="#C5A46D", dash="dash", width=2)
      ),
      list(
        type="line",
        x0=media_horas, x1=media_horas,
        y0=0, y1=max_y,
        line=list(color="#171717", width=2)
      )
    ),
    annotations=list(
      list(
        x=mediana_horas, y=max_y*0.94,
        text=paste("Mediana:", round(mediana_horas, 1)),
        showarrow=FALSE,
        font=list(size=10, color="#A78342"),
        bgcolor="white",
        bordercolor="#C5A46D",
        borderpad=3
      ),
      list(
        x=media_horas, y=max_y*0.82,
        text=paste("Media:", round(media_horas, 1)),
        showarrow=FALSE,
        font=list(size=10, color="#171717"),
        bgcolor="white",
        bordercolor="#171717",
        borderpad=3
      ),
      list(
        x=1, y=-0.23,
        xref="paper", yref="paper",
        xanchor="right",
        text=paste(
          "Fuente: GEIH, DANE, junio de 2025 | N =",
          scales::comma(n), "trabajadores"
        ),
        showarrow=FALSE,
        font=list(family="Source Serif Pro", size=9, color="#858585")
      )
    ),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=60, r=25, t=65, b=65),
    hoverlabel=list(font=list(family="Source Serif Pro")),
    legend=list(
      orientation="h", x=0.5, y=-0.14, xanchor="center",
      font=list(size=10)
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d", "lasso2d", "select2d"),
    displaylogo=FALSE
  )

p_horas

3.4 Comparación de las variables cuantitativas según la categoría de ingreso

La Tabla 5 presenta las estadísticas descriptivas de la edad y las horas trabajadas por semana, diferenciando a los trabajadores según su categoría de ingreso laboral: Bajo y Alto. Esta comparación permite identificar diferencias descriptivas entre ambos grupos y complementar los resultados observados en los diagramas de caja.

library(dplyr)
library(kableExtra)

resumir_variable <- function(variable, etiqueta) {
  base_tabla %>%
    filter(!is.na(ingreso_f), !is.na(.data[[variable]])) %>%
    group_by(ingreso_f) %>%
    summarise(
      N=n(),
      Media=mean(.data[[variable]]),
      Mediana=median(.data[[variable]]),
      `Desv. estándar`=sd(.data[[variable]]),
      Mínimo=min(.data[[variable]]),
      Máximo=max(.data[[variable]]),
      .groups="drop"
    ) %>%
    mutate(Variable=etiqueta) %>%
    rename(`Categoría de ingreso`=ingreso_f) %>%
    select(
      Variable, `Categoría de ingreso`, N,
      Media, Mediana, `Desv. estándar`, Mínimo, Máximo
    )
}

tabla_por_ingreso <- bind_rows(
  resumir_variable("edad", "Edad (años)"),
  resumir_variable("horas_trabajadas", "Horas trabajadas por semana")
) %>%
  mutate(
    `Categoría de ingreso`=factor(
      `Categoría de ingreso`,
      levels=c("Bajo", "Alto")
    )
  ) %>%
  arrange(Variable, `Categoría de ingreso`) %>%
  mutate(`Categoría de ingreso`=as.character(`Categoría de ingreso`))

tabla_por_ingreso$N <- format(
  tabla_por_ingreso$N,
  big.mark=".",
  scientific=FALSE,
  trim=TRUE
)

tabla_por_ingreso[4:8] <- lapply(
  tabla_por_ingreso[4:8],
  function(x) format(
    round(x, 2),
    big.mark=".",
    decimal.mark=",",
    scientific=FALSE,
    nsmall=2,
    trim=TRUE
  )
)

tabla_por_ingreso %>%
  kbl(
    format="html",
    caption="Tabla 5. Estadísticas descriptivas según categoría de ingreso",
    col.names=c(
      "Variable", "Categoría de ingreso", "N",
      "Media", "Mediana", "Desv. estándar", "Mínimo", "Máximo"
    ),
    align=c("l","l","r","r","r","r","r","r"),
    escape=TRUE
  ) %>%
  kable_styling(
    bootstrap_options=c("hover","condensed"),
    full_width=FALSE,
    font_size=12,
    position="center"
  ) %>%
  row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  column_spec(
    1:2,
    bold=TRUE,
    extra_css="color:#292929;"
  ) %>%
  column_spec(3:8, width="2cm") %>%
  footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 5. Estadísticas descriptivas según categoría de ingreso
Variable Categoría de ingreso N Media Mediana Desv. estándar Mínimo Máximo
Edad (años) Bajo 6.948 36,69 34,00 12,70 18,00 85,00
Edad (años) Alto 6.937 40,02 39,00 11,83 18,00 92,00
Horas trabajadas por semana Bajo 6.948 45,58 47,00 9,93 2,00 105,00
Horas trabajadas por semana Alto 6.937 46,93 47,00 8,36 9,00 130,00
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.

3.4.1 Comparación de la edad según la categoría de ingreso

Los resultados muestran que los trabajadores con ingresos Altos presentan una edad promedio de 40,02 años, superior a los 36,69 años registrados en el grupo de ingresos Bajos. Asimismo, la mediana es de 39 años para el grupo Alto y de 34 años para el grupo Bajo. Esta diferencia indica que, en esta muestra, los trabajadores clasificados con ingresos Altos tienden a presentar mayores edades.

En cuanto a la variabilidad, la desviación estándar es de 11,83 años para el grupo Alto y de 12,70 años para el grupo Bajo, lo que refleja una dispersión ligeramente menor de las edades en el primer grupo. Estos resultados muestran una diferencia descriptiva entre las edades de ambos grupos, aunque por sí solos no permiten establecer una relación causal entre la edad y el nivel de ingreso.

library(dplyr)
library(plotly)

datos_edad <- base_tabla %>%
  filter(!is.na(edad), !is.na(ingreso_f)) %>%
  mutate(ingreso_f=factor(ingreso_f, levels=c("Bajo","Alto")))

boxplot_edad <- plot_ly(
  data=datos_edad,
  x=~ingreso_f,
  y=~edad,
  color=~ingreso_f,
  colors=c("#858585","#C5A46D"),
  type="box",
  boxpoints="outliers",
  pointpos=0,
  hoverinfo="y+x+name",
  marker=list(size=4, line=list(width=0.5, color="#171717")),
  line=list(width=1.2, color="#171717")
) %>%
  layout(
    title=list(
      text="<b>Figura 5. Distribución de la edad según categoría de ingreso</b>",
      x=0.05,
      font=list(size=17, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title=list(text="<b>Categoría de ingreso</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=11, color="#444444"),
      showgrid=FALSE,
      linecolor="#C5A46D"
    ),
    yaxis=list(
      title=list(text="<b>Edad (años)</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=10, color="#444444"),
      gridcolor="#E0E0E0",
      zerolinecolor="#C5A46D"
    ),
    showlegend=FALSE,
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=60, r=25, t=70, b=75),
    hoverlabel=list(bgcolor="white", bordercolor="#C5A46D",
                    font=list(size=11, color="#171717")),
    annotations=list(
      list(
        x=1, y=-0.22,
        text="Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        xref="paper", yref="paper", xanchor="right",
        showarrow=FALSE,
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(displayModeBar=TRUE, displaylogo=FALSE)

boxplot_edad

3.4.2 Comparación de las horas trabajadas según la categoría de ingreso

Respecto a las horas trabajadas por semana, los trabajadores con ingresos Altos registran un promedio de 46,93 horas, mientras que en el grupo de ingresos Bajos el promedio es de 45,58 horas. La diferencia entre ambos promedios es de 1,35 horas semanales. Sin embargo, la mediana es de 47 horas en las dos categorías, lo que indica que el valor central de las horas trabajadas es igual en ambos grupos.

La desviación estándar es de 8,36 horas para los trabajadores con ingresos Altos y de 9,93 horas para aquellos con ingresos Bajos. Esto sugiere una menor dispersión de las horas trabajadas en el grupo Alto. También se observan diferencias en los valores extremos: el máximo registrado es de 130 horas semanales en el grupo Alto y de 105 horas en el grupo Bajo, por lo que conviene examinar la posible presencia de observaciones atípicas en los diagramas de caja.

En conjunto, los resultados descriptivos muestran que el grupo de ingresos Altos presenta una mayor edad promedio y una cantidad promedio de horas trabajadas ligeramente superior. No obstante, las diferencias observadas no implican necesariamente que estas características determinen por sí solas el nivel de ingreso, por lo que deben analizarse junto con las demás variables predictoras y los resultados de los modelos de clasificación.

datos_horas <- base_tabla %>%
  filter(!is.na(horas_trabajadas), !is.na(ingreso_f)) %>%
  mutate(ingreso_f=factor(ingreso_f, levels=c("Bajo","Alto")))

boxplot_horas <- plot_ly(
  data=datos_horas,
  x=~ingreso_f,
  y=~horas_trabajadas,
  color=~ingreso_f,
  colors=c("#858585","#C5A46D"),
  type="box",
  boxpoints="outliers",
  pointpos=0,
  hoverinfo="y+x+name",
  marker=list(size=4, line=list(width=0.5, color="#171717")),
  line=list(width=1.2, color="#171717")
) %>%
  layout(
    title=list(
      text="<b>Figura 6. Distribución de las horas trabajadas según categoría de ingreso</b>",
      x=0.05,
      font=list(size=17, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title=list(text="<b>Categoría de ingreso</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=11, color="#444444"),
      showgrid=FALSE,
      linecolor="#C5A46D"
    ),
    yaxis=list(
      title=list(text="<b>Horas trabajadas por semana</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=10, color="#444444"),
      gridcolor="#E0E0E0",
      zerolinecolor="#C5A46D"
    ),
    showlegend=FALSE,
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    margin=list(l=60, r=25, t=70, b=75),
    hoverlabel=list(bgcolor="white", bordercolor="#C5A46D",
                    font=list(size=11, color="#171717")),
    annotations=list(
      list(
        x=1, y=-0.22,
        text="Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        xref="paper", yref="paper", xanchor="right",
        showarrow=FALSE,
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(displayModeBar=TRUE, displaylogo=FALSE)

boxplot_horas

3.5 Relación entre las variables categóricas y la variable dependiente

En esta sección se analiza la distribución de las categorías de ingreso laboral Bajo y Alto según las características de los trabajadores, considerando las variables sexo, nivel educativo y tipo de empleado. Para ello, se utilizan los resultados de la tabla de frecuencias cruzadas y los gráficos de barras apiladas proporcionales, que permiten comparar los porcentajes de cada categoría de ingreso dentro de los grupos analizados.

library(dplyr)
library(kableExtra)

tabla_ingreso_caracteristicas <- bind_rows(
  base_tabla %>%
    count(sexo, ingreso_f, name="Frecuencia") %>%
    group_by(sexo) %>%
    mutate(Porcentaje=100*Frecuencia/sum(Frecuencia)) %>%
    ungroup() %>%
    transmute(
      Variable="Sexo",
      Caracteristica=as.character(sexo),
      Categoria_ingreso=as.character(ingreso_f),
      Frecuencia,
      Porcentaje
    ),

  base_tabla %>%
    count(nivel_educativo, ingreso_f, name="Frecuencia") %>%
    group_by(nivel_educativo) %>%
    mutate(Porcentaje=100*Frecuencia/sum(Frecuencia)) %>%
    ungroup() %>%
    transmute(
      Variable="Nivel educativo",
      Caracteristica=as.character(nivel_educativo),
      Categoria_ingreso=as.character(ingreso_f),
      Frecuencia,
      Porcentaje
    ),

  base_tabla %>%
    count(tipo_empleado, ingreso_f, name="Frecuencia") %>%
    group_by(tipo_empleado) %>%
    mutate(Porcentaje=100*Frecuencia/sum(Frecuencia)) %>%
    ungroup() %>%
    transmute(
      Variable="Tipo de empleado",
      Caracteristica=as.character(tipo_empleado),
      Categoria_ingreso=as.character(ingreso_f),
      Frecuencia,
      Porcentaje
    )
)

tabla_ingreso_caracteristicas$Frecuencia <- format(
  tabla_ingreso_caracteristicas$Frecuencia,
  big.mark=".",
  scientific=FALSE,
  trim=TRUE
)

tabla_ingreso_caracteristicas$Porcentaje <- format(
  round(tabla_ingreso_caracteristicas$Porcentaje, 2),
  big.mark=".",
  decimal.mark=",",
  nsmall=2,
  trim=TRUE
)

tabla_ingreso_caracteristicas %>%
  kbl(
    format="html",
    caption="Tabla 6. Distribución de las categorías de ingreso según las características de los trabajadores",
    col.names=c(
      "Variable",
      "Característica",
      "Categoría de ingreso",
      "Frecuencia",
      "Porcentaje (%)"
    ),
    align=c("l","l","l","r","r"),
    escape=TRUE
  ) %>%
  kable_styling(
    bootstrap_options=c("hover","condensed"),
    full_width=FALSE,
    font_size=13,
    position="center"
  ) %>%
  row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  column_spec(
    1,
    bold=TRUE,
    width="3cm",
    extra_css="color:#292929;"
  ) %>%
  column_spec(2, width="3.2cm") %>%
  column_spec(3, width="3cm") %>%
  column_spec(4:5, width="2.5cm") %>%
  footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 6. Distribución de las categorías de ingreso según las características de los trabajadores
Variable Característica Categoría de ingreso Frecuencia Porcentaje (%)
Sexo Hombre Alto 3.795 51,17
Sexo Hombre Bajo 3.621 48,83
Sexo Mujer Alto 3.142 48,57
Sexo Mujer Bajo 3.327 51,43
Nivel educativo Postgrado Alto 1.151 98,04
Nivel educativo Postgrado Bajo 23 1,96
Nivel educativo Primaria Alto 507 24,72
Nivel educativo Primaria Bajo 1.544 75,28
Nivel educativo Secundaria Alto 1.738 35,03
Nivel educativo Secundaria Bajo 3.224 64,97
Nivel educativo Técnica/Tecnología Alto 1.345 51,00
Nivel educativo Técnica/Tecnología Bajo 1.292 49,00
Nivel educativo Universitaria Alto 2.196 71,74
Nivel educativo Universitaria Bajo 865 28,26
Tipo de empleado Gobierno Alto 1.494 95,95
Tipo de empleado Gobierno Bajo 63 4,05
Tipo de empleado Particular Alto 5.443 44,15
Tipo de empleado Particular Bajo 6.885 55,85
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.

3.5.1. Categoría de ingreso según nivel educativo

La distribución de las categorías de ingreso muestra diferencias importantes según el nivel educativo. Entre los trabajadores con educación primaria, el 75,28 % pertenece a la categoría de ingreso Bajo y el 24,72 % a la categoría Alto. En el nivel de secundaria, las proporciones corresponden al 64,97 % y al 35,03 %, respectivamente.

Por su parte, entre quienes cuentan con formación técnica o tecnológica, el 51,00 % pertenece a la categoría Alto y el 49,00 % a la categoría Bajo. En el grupo con educación universitaria, la proporción de ingresos Altos alcanza el 71,74 %, mientras que la de ingresos Bajos es del 28,26 %. Finalmente, entre los trabajadores con estudios de posgrado, el 98,04 % se clasifica en la categoría Alto y el 1,96 % en la categoría Bajo.

En conjunto, se observa un patrón descriptivo en el que los niveles educativos más altos presentan mayores proporciones de trabajadores clasificados con ingresos Altos. Esta tendencia sugiere que el nivel educativo constituye una característica relevante para diferenciar los grupos de ingreso en la muestra, aunque estos resultados no permiten establecer por sí solos una relación causal.

library(plotly)
library(dplyr)

tabla_relacion <- base_tabla %>%
  filter(!is.na(nivel_educativo), !is.na(ingreso_f)) %>%
  count(nivel_educativo, ingreso_f, name="Frecuencia") %>%
  group_by(nivel_educativo) %>%
  mutate(Porcentaje=Frecuencia/sum(Frecuencia)*100) %>%
  ungroup() %>%
  mutate(
    ingreso_f=factor(ingreso_f, levels=c("Bajo","Alto")),
    nivel_educativo=factor(
      nivel_educativo,
      levels=c("Primaria","Secundaria","Técnica/Tecnología",
               "Universitaria","Postgrado")
    )
  )

colores_ingreso <- c("Bajo"="#858585", "Alto"="#C5A46D")

plot_ly(
  data=tabla_relacion,
  x=~nivel_educativo,
  y=~Porcentaje,
  color=~ingreso_f,
  colors=colores_ingreso,
  type="bar",
  customdata=~Frecuencia,
  text=~ifelse(Porcentaje>=6, paste0(round(Porcentaje,1),"%"), ""),
  textposition="inside",
  textfont=list(color="#171717", size=11, family="Source Serif Pro"),
  hovertemplate=paste(
    "<b>Nivel educativo:</b> %{x}<br>",
    "<b>Ingreso:</b> %{fullData.name}<br>",
    "<b>Frecuencia:</b> %{customdata}<br>",
    "<b>Porcentaje:</b> %{y:.1f}%<extra></extra>"
  ),
  marker=list(line=list(color="white", width=1))
) %>%
  layout(
    barmode="stack",
    barnorm="percent",
    title=list(
      text="<b>Figura 7. Categoría de ingreso según nivel educativo</b>",
      x=0.05,
      font=list(size=18, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title=list(text="<b>Nivel educativo</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=10, color="#292929"),
      categoryorder="array",
      categoryarray=c("Primaria","Secundaria","Técnica/Tecnología",
                      "Universitaria","Postgrado")
    ),
    yaxis=list(
      title=list(text="<b>Porcentaje de trabajadores (%)</b>",
                 font=list(size=12, color="#171717")),
      ticksuffix="%",
      range=c(0,100),
      dtick=20,
      gridcolor="#E0E0E0"
    ),
    legend=list(
      title=list(text="<b>Categoría de ingreso</b>"),
      orientation="h",
      x=0.5,
      xanchor="center",
      y=-0.23,
      font=list(size=11, color="#292929")
    ),
    margin=list(l=60, r=30, t=75, b=110),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    hoverlabel=list(
      bgcolor="white",
      bordercolor="#C5A46D",
      font=list(color="#171717", size=11)
    ),
    annotations=list(
      list(
        text="Los porcentajes se calculan dentro de cada nivel educativo.",
        x=0, y=-0.34,
        xref="paper", yref="paper",
        showarrow=FALSE,
        xanchor="left",
        font=list(size=10, color="#858585")
      ),
      list(
        text=paste("Fuente: GEIH, DANE, junio de 2025 | N =",
                   scales::comma(sum(tabla_relacion$Frecuencia))),
        x=1, y=-0.42,
        xref="paper", yref="paper",
        showarrow=FALSE,
        xanchor="right",
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d","lasso2d","select2d"),
    displaylogo=FALSE
  )

3.5.2. Categoría de ingreso según sexo

Los resultados muestran diferencias moderadas en la distribución de las categorías de ingreso según el sexo. Entre los hombres, el 51,17 % pertenece a la categoría de ingreso Alto, mientras que el 48,83 % se encuentra en la categoría Bajo. En contraste, entre las mujeres, el 48,57 % presenta ingresos Altos y el 51,43 % ingresos Bajos.

Estas cifras indican que, dentro de la muestra analizada, la proporción de ingresos Altos es ligeramente mayor entre los hombres, mientras que entre las mujeres predomina ligeramente la categoría de ingreso Bajo. Sin embargo, las diferencias porcentuales son reducidas, por lo que el sexo, considerado de manera aislada, presenta una separación descriptiva limitada entre ambas categorías de ingreso.

library(plotly)
library(dplyr)
library(scales)

colores_ingreso <- c("Bajo"="#858585", "Alto"="#C5A46D")

tabla_sexo <- base_tabla %>%
  filter(!is.na(sexo), !is.na(ingreso_f)) %>%
  count(sexo, ingreso_f, name="Frecuencia") %>%
  group_by(sexo) %>%
  mutate(Porcentaje=Frecuencia/sum(Frecuencia)*100) %>%
  ungroup() %>%
  mutate(
    ingreso_f=factor(ingreso_f, levels=c("Bajo","Alto")),
    sexo=factor(sexo, levels=c("Hombre","Mujer"))
  )

plot_ly(
  data=tabla_sexo,
  x=~sexo,
  y=~Porcentaje,
  color=~ingreso_f,
  colors=colores_ingreso,
  type="bar",
  customdata=~Frecuencia,
  text=~ifelse(Porcentaje>=6, paste0(round(Porcentaje,1),"%"), ""),
  textposition="inside",
  textfont=list(color="#171717", size=11, family="Source Serif Pro"),
  hovertemplate=paste(
    "<b>Sexo:</b> %{x}<br>",
    "<b>Categoría de ingreso:</b> %{fullData.name}<br>",
    "<b>Frecuencia:</b> %{customdata}<br>",
    "<b>Porcentaje:</b> %{y:.1f}%<extra></extra>"
  ),
  marker=list(line=list(color="white", width=1))
) %>%
  layout(
    barmode="stack",
    barnorm="percent",
    title=list(
      text="<b>Figura 8. Categoría de ingreso según sexo</b>",
      x=0.05,
      font=list(size=18, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title=list(text="<b>Sexo</b>", font=list(size=12, color="#171717")),
      tickfont=list(size=11, color="#292929"),
      categoryorder="array",
      categoryarray=c("Hombre","Mujer")
    ),
    yaxis=list(
      title=list(text="<b>Porcentaje de trabajadores</b>",
                 font=list(size=12, color="#171717")),
      ticksuffix="%",
      range=c(0,100),
      dtick=20,
      gridcolor="#E0E0E0"
    ),
    legend=list(
      title=list(text="<b>Categoría de ingreso</b>"),
      orientation="h",
      x=0.5, xanchor="center", y=-0.23,
      font=list(size=11, color="#292929")
    ),
    margin=list(l=60, r=30, t=75, b=100),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    hoverlabel=list(
      bgcolor="white",
      bordercolor="#C5A46D",
      font=list(color="#171717", size=11)
    ),
    annotations=list(
      list(
        text="Los porcentajes se calculan dentro de cada categoría de sexo.",
        x=0, y=-0.32,
        xref="paper", yref="paper",
        showarrow=FALSE, xanchor="left",
        font=list(size=10, color="#858585")
      ),
      list(
        text=paste("Fuente: GEIH, DANE, junio de 2025 | N =",
                   scales::comma(sum(tabla_sexo$Frecuencia))),
        x=1, y=-0.42,
        xref="paper", yref="paper",
        showarrow=FALSE, xanchor="right",
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d","lasso2d","select2d"),
    displaylogo=FALSE
  )

3.5.3. Categoría de ingreso según tipo de empleado

Al comparar las categorías de ingreso según el tipo de empleado, se observan diferencias marcadas entre los empleados del gobierno y los particulares. En el grupo de empleados del gobierno, el 95,95 % pertenece a la categoría de ingreso Alto y el 4,05 % a la categoría Bajo.

En cambio, entre los empleados particulares, el 44,15 % se clasifica en la categoría Alto y el 55,85 % en la categoría Bajo. Por tanto, en la muestra analizada, la proporción de trabajadores con ingresos Altos es considerablemente mayor entre los empleados del gobierno, mientras que entre los particulares predomina la categoría de ingreso Bajo.

Estas diferencias muestran que el tipo de empleado presenta una distribución claramente distinta según la categoría de ingreso laboral. No obstante, deben interpretarse como asociaciones descriptivas y no como evidencia de que el tipo de empleado, por sí solo, determine el nivel de ingreso.

tabla_tipo <- base_tabla %>%
  filter(!is.na(tipo_empleado), !is.na(ingreso_f)) %>%
  count(tipo_empleado, ingreso_f, name="Frecuencia") %>%
  group_by(tipo_empleado) %>%
  mutate(Porcentaje=Frecuencia/sum(Frecuencia)*100) %>%
  ungroup() %>%
  mutate(
    ingreso_f=factor(ingreso_f, levels=c("Bajo","Alto")),
    tipo_empleado=factor(tipo_empleado, levels=c("Particular","Gobierno"))
  )

plot_ly(
  data=tabla_tipo,
  x=~tipo_empleado,
  y=~Porcentaje,
  color=~ingreso_f,
  colors=colores_ingreso,
  type="bar",
  customdata=~Frecuencia,
  text=~ifelse(Porcentaje>=6, paste0(round(Porcentaje,1),"%"), ""),
  textposition="inside",
  textfont=list(color="#171717", size=11, family="Source Serif Pro"),
  hovertemplate=paste(
    "<b>Tipo de empleado:</b> %{x}<br>",
    "<b>Categoría de ingreso:</b> %{fullData.name}<br>",
    "<b>Frecuencia:</b> %{customdata}<br>",
    "<b>Porcentaje:</b> %{y:.1f}%<extra></extra>"
  ),
  marker=list(line=list(color="white", width=1))
) %>%
  layout(
    barmode="stack",
    barnorm="percent",
    title=list(
      text="<b>Figura 9. Categoría de ingreso según tipo de empleado</b>",
      x=0.05,
      font=list(size=18, color="#171717", family="Playfair Display")
    ),
    xaxis=list(
      title=list(text="<b>Tipo de empleado</b>",
                 font=list(size=12, color="#171717")),
      tickfont=list(size=11, color="#292929"),
      categoryorder="array",
      categoryarray=c("Particular","Gobierno")
    ),
    yaxis=list(
      title=list(text="<b>Porcentaje de trabajadores</b>",
                 font=list(size=12, color="#171717")),
      ticksuffix="%",
      range=c(0,100),
      dtick=20,
      gridcolor="#E0E0E0"
    ),
    legend=list(
      title=list(text="<b>Categoría de ingreso</b>"),
      orientation="h",
      x=0.5, xanchor="center", y=-0.23,
      font=list(size=11, color="#292929")
    ),
    margin=list(l=60, r=30, t=75, b=100),
    plot_bgcolor="#F5F5F5",
    paper_bgcolor="white",
    font=list(family="Source Serif Pro", size=11, color="#171717"),
    hoverlabel=list(
      bgcolor="white",
      bordercolor="#C5A46D",
      font=list(color="#171717", size=11)
    ),
    annotations=list(
      list(
        text="Los porcentajes se calculan dentro de cada tipo de empleado.",
        x=0, y=-0.32,
        xref="paper", yref="paper",
        showarrow=FALSE, xanchor="left",
        font=list(size=10, color="#858585")
      ),
      list(
        text=paste("Fuente: GEIH, DANE, junio de 2025 | N =",
                   scales::comma(sum(tabla_tipo$Frecuencia))),
        x=1, y=-0.42,
        xref="paper", yref="paper",
        showarrow=FALSE, xanchor="right",
        font=list(size=9, color="#858585")
      )
    )
  ) %>%
  config(
    displayModeBar=TRUE,
    modeBarButtonsToRemove=c("pan2d","lasso2d","select2d"),
    displaylogo=FALSE
  )

En general, las comparaciones muestran diferencias de distinta magnitud entre las categorías de ingreso. Las diferencias según sexo son relativamente pequeñas, mientras que el nivel educativo y el tipo de empleado presentan contrastes más marcados. En particular, los trabajadores con educación universitaria o de posgrado y los empleados del gobierno registran mayores proporciones de ingreso Alto dentro de sus respectivos grupos.

Estos resultados aportan una primera aproximación a las características que podrían ayudar a distinguir entre trabajadores con ingresos Bajos y Altos. Posteriormente, los modelos KNN y regresión logística permitirán evaluar qué tan bien pueden clasificarse las observaciones a partir del conjunto de variables predictoras.

3.6 Correlación

El análisis de correlación permite examinar la dirección y la intensidad de la relación lineal entre las variables cuantitativas del estudio. Para ello, se utiliza el coeficiente de correlación de Pearson, cuyos valores oscilan entre −1 y 1. Los valores positivos indican una relación lineal directa, mientras que los negativos representan una relación inversa. Por su parte, los coeficientes cercanos a cero indican una relación lineal muy débil o prácticamente inexistente.

3.6.1 Matriz de correlaciones entre variables numéricas

De acuerdo con los resultados de la Tabla 7, la correlación entre la edad y el ingreso laboral mensual es de 0,173, lo que indica una relación lineal positiva débil. Esto sugiere que, en la muestra analizada, los trabajadores de mayor edad tienden a presentar ingresos ligeramente superiores. Sin embargo, la magnitud del coeficiente muestra que esta asociación lineal es limitada.

Por otra parte, la correlación entre la edad y las horas trabajadas por semana es de −0,017. Este valor se encuentra muy próximo a cero, por lo que no se observa una relación lineal relevante entre ambas variables en la muestra. En otras palabras, las diferencias de edad no presentan una asociación lineal apreciable con la cantidad de horas trabajadas semanalmente.

Finalmente, la correlación entre las horas trabajadas y el ingreso laboral mensual es de 0,006, lo que representa una asociación lineal prácticamente nula. Este resultado indica que la cantidad de horas trabajadas por semana, considerada de manera aislada, no presenta una relación lineal apreciable con el ingreso laboral mensual dentro de los datos analizados.

En conjunto, la correlación de mayor magnitud entre las tres parejas de variables corresponde a la relación entre edad e ingreso laboral, aunque sigue siendo débil. Las otras dos relaciones son prácticamente nulas. Estos resultados muestran que las variables cuantitativas presentan asociaciones lineales limitadas entre sí, pero no permiten concluir que carezcan de utilidad para la clasificación. La capacidad de las variables para distinguir entre las categorías de ingreso Bajo y Alto deberá evaluarse mediante los resultados de los modelos KNN y regresión logística.

Es importante señalar que la correlación de Pearson mide asociaciones lineales y no implica causalidad. Por tanto, estos coeficientes deben interpretarse como una descripción inicial de las relaciones entre las variables, y no como evidencia de que una característica provoque cambios en otra.

vars <- base_tabla[, c("edad", "horas_trabajadas", "ingreso_laboral")]

matriz <- cor(
  vars,
  method="pearson",
  use="complete.obs"
)

knitr::kable(
  round(matriz, 3),
  format="html",
  caption="Tabla 7. Matriz de correlaciones de Pearson entre las variables cuantitativas",
  align="r"
) %>%
  kableExtra::kable_styling(
    bootstrap_options=c("hover", "condensed"),
    full_width=FALSE,
    font_size=13,
    position="center"
  ) %>%
  kableExtra::row_spec(
    0,
    background="#171717",
    color="#E0CCA0",
    bold=TRUE,
    extra_css="border-bottom: 2px solid #C5A46D;"
  ) %>%
  kableExtra::column_spec(
    1,
    bold=TRUE,
    color="#292929"
  ) %>%
  kableExtra::row_spec(
    1:nrow(matriz),
    color="#292929"
  ) %>%
  kableExtra::footnote(
    general="Elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title="Fuente:",
    footnote_as_chunk=TRUE
  )
Tabla 7. Matriz de correlaciones de Pearson entre las variables cuantitativas
edad horas_trabajadas ingreso_laboral
edad 1.000 -0.017 0.173
horas_trabajadas -0.017 1.000 0.006
ingreso_laboral 0.173 0.006 1.000
Fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025.
library(corrplot)

corrplot(
  matriz,
  method="color",
  type="upper",
  addCoef.col="#171717",
  tl.col="#171717",
  tl.srt=45,
  col=colorRampPalette(c("#858585", "#F5F5F5", "#C5A46D"))(200),
  number.cex=0.9,
  tl.cex=0.9,
  cl.cex=0.8,
  bg="white"
)

4 Modelos de clasificación

La clasificación es un enfoque fundamental dentro del aprendizaje supervisado cuyo objetivo es asignar observaciones a categorías predefinidas a partir de un conjunto conocido de características. Los modelos de este tipo aprenden de datos etiquetados, en los que cada observación tiene una categoría asociada, para luego predecir la categoría de observaciones nuevas.

En este trabajo el problema de clasificación es binario: se busca predecir si el ingreso laboral mensual de un trabajador es bajo (menor o igual a la mediana de la muestra, $1.580.000 COP) o alto (superior a ella), tomando la categoría Alto como clase positiva. Como variables explicativas se emplean el sexo, la edad, el nivel educativo, las horas trabajadas por semana y el tipo de empleado.

A continuación se detalla la división de los datos en conjuntos de entrenamiento y prueba, y la implementación de los dos modelos utilizados, regresión logística (Logit) y K vecinos más cercanos (KNN), cuyo desempeño se evalúa y compara posteriormente.

4.1 División de los datos

Para el desarrollo de los modelos de clasificación se utilizó la base analítica descrita en la metodología, conformada por 13.885 observaciones, de las cuales 6.948 pertenecen a la categoría de ingreso bajo y 6.937 a la de ingreso alto. Como las dos clases están prácticamente equilibradas, la partición se realizó de forma aleatoria y estratificada, para garantizar que ambas categorías conservaran su proporción en los subconjuntos de entrenamiento y prueba.

Con una semilla fija (set.seed(28)) para asegurar la reproducibilidad de los resultados, el 75 % de las observaciones se destinó al conjunto de entrenamiento, utilizado para ajustar los modelos predictivos, mientras que el 25 % restante se reservó como conjunto de prueba, empleado para evaluar el desempeño sobre observaciones que no participaron en la estimación.

Esta proporción 75/25 sigue las recomendaciones habituales en aprendizaje supervisado, ya que permite disponer de suficientes datos para el entrenamiento y, al mismo tiempo, mantiene un subconjunto independiente para validar la capacidad de generalización de los modelos. La división se implementó mediante la función createDataPartition() del paquete caret, que genera los índices de las observaciones de entrenamiento respetando la proporción de la variable dependiente. Los mismos subconjuntos se emplean tanto para KNN como para Logit, de modo que la comparación entre ambos modelos se haga sobre las mismas observaciones.

# Base con la variable dependiente y las cinco variables explicativas
base_modelo <- base_2 %>%
  select(ingreso_f, sexo, edad, nivel_educativo,
         horas_trabajadas, tipo_empleado) %>%
  drop_na() %>%
  mutate(
    sexo            = factor(sexo),
    nivel_educativo = factor(nivel_educativo),
    tipo_empleado   = factor(tipo_empleado)
  )

# Partición estratificada 75 % entrenamiento / 25 % prueba
set.seed(28)

index_entrena <- createDataPartition(
  y    = base_modelo$ingreso_f,
  p    = 0.75,
  list = FALSE
)

train <- base_modelo[index_entrena, ]
test  <- base_modelo[-index_entrena, ]

Como resultado, el conjunto de entrenamiento quedó conformado por 10.414 observaciones y el de prueba por 3.471. La siguiente tabla permite verificar que la proporción de trabajadores con ingreso alto se mantiene prácticamente igual en ambos conjuntos.

resumen_division <- bind_rows(
  tibble(Conjunto = "Entrenamiento",
         Observaciones = nrow(train),
         Bajo = sum(train$ingreso_f == "Bajo"),
         Alto = sum(train$ingreso_f == "Alto")),
  tibble(Conjunto = "Prueba",
         Observaciones = nrow(test),
         Bajo = sum(test$ingreso_f == "Bajo"),
         Alto = sum(test$ingreso_f == "Alto"))
) %>%
  mutate(`% Alto` = round(100 * Alto / Observaciones, 2))

knitr::kable(
  resumen_division,
  format = "html",
  format.args = list(big.mark = ".", decimal.mark = ","),
  caption = "Tabla 8. Distribución de las categorías de ingreso en los conjuntos de entrenamiento y prueba",
  align = c("l", "r", "r", "r", "r"),
  escape = TRUE
) %>%
  kableExtra::kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  kableExtra::row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  kableExtra::row_spec(
    1,
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  kableExtra::row_spec(
    2,
    background = "#FFFFFF",
    color = "#292929"
  ) %>%
  kableExtra::column_spec(
    1,
    bold = TRUE,
    color = "#292929",
    width = "3cm"
  ) %>%
  kableExtra::column_spec(
    2:5,
    color = "#333333",
    width = "2.5cm"
  )
Tabla 8. Distribución de las categorías de ingreso en los conjuntos de entrenamiento y prueba
Conjunto Observaciones Bajo Alto % Alto
Entrenamiento 10.414 5.211 5.203 49,96
Prueba 3.471 1.737 1.734 49,96

Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.

4.2 Modelo KNN

El modelo K vecinos más cercanos (K-Nearest Neighbors, KNN) es un algoritmo de aprendizaje supervisado no paramétrico, ampliamente utilizado en problemas de clasificación. Para predecir la clase de una nueva observación, identifica las k observaciones del conjunto de entrenamiento que más se le parecen en el espacio de las variables explicativas y le asigna la categoría que predomina entre ellas.

Este método opera de manera “perezosa”: no construye un modelo explícito durante el entrenamiento, sino que almacena los datos y calcula las distancias únicamente en el momento de la predicción. Esto lo hace simple e intuitivo y le permite capturar patrones locales en los datos. Como se basa en distancias, es sensible a la escala de las variables, por lo que requiere estandarizarlas previamente.

4.2.1 Modelo KNN (class)

El modelo se ajustó con la función knn() del paquete class, que solo admite variables numéricas. Por esta razón, antes de aplicar el algoritmo las variables categóricas (sexo, nivel educativo y tipo de empleado) se transformaron en columnas indicadoras mediante model.matrix(), mientras que la edad y las horas trabajadas se conservaron como variables cuantitativas.

Posteriormente, todas las columnas predictoras se estandarizaron con las medias y desviaciones estándar calculadas únicamente con el conjunto de entrenamiento, y esos mismos parámetros se aplicaron al conjunto de prueba. De este modo ninguna variable domina el cálculo de distancias por tener una escala mayor, y no se filtra información del conjunto de prueba hacia el entrenamiento.

vars_modelo <- c("sexo", "edad", "nivel_educativo",
                 "horas_trabajadas", "tipo_empleado")

# Variables categóricas -> columnas indicadoras
x_train <- model.matrix(~ . - 1, data = train[, vars_modelo])
x_test  <- model.matrix(~ . - 1, data = test[, vars_modelo])

centros <- colMeans(x_train)
escalas <- apply(x_train, 2, sd)
escalas[escalas == 0] <- 1

x_train <- scale(x_train, center = centros, scale = escalas)
x_test  <- scale(x_test,  center = centros, scale = escalas)

y_train <- train$ingreso_f
y_test  <- test$ingreso_f

dim(x_train)
## [1] 10414     9

Para elegir el número de vecinos k se evaluó la exactitud del modelo para los valores impares entre 1 y 101 (los valores impares reducen los empates en la votación). La evaluación se hizo mediante validación cruzada estratificada de 5 particiones dentro del conjunto de entrenamiento, de manera que el conjunto de prueba no interviene en esta decisión y se conserva como evaluación final independiente. El valor de k seleccionado es el que maximiza la exactitud promedio de las particiones.

set.seed(28)

k_vals <- seq(1, 101, by = 2)
folds  <- createFolds(y_train, k = 5)   

exactitud_cv <- sapply(k_vals, function(k) {
  mean(sapply(folds, function(idx) {
    pred <- class::knn(
      train = x_train[-idx, ],
      test  = x_train[idx, ],
      cl    = y_train[-idx],
      k     = k
    )
    mean(pred == y_train[idx])
  }))
})

resultado_k <- data.frame(k = k_vals, exactitud = exactitud_cv)

k_optimo   <- resultado_k$k[which.max(resultado_k$exactitud)]
exac_optima <- max(resultado_k$exactitud)

cat("k óptimo:", k_optimo, "| Exactitud en validación cruzada:", round(exac_optima, 4), "\n")
## k óptimo: 41 | Exactitud en validación cruzada: 0.7258

La siguiente figura muestra la exactitud del modelo para cada valor de k. Es interactiva: al pasar el cursor sobre la línea se muestran el valor de k, la exactitud en porcentaje y su valor exacto.

rojo       <- "#171717"
rojo_osc   <- "#4A443A"
rojo_claro <- "#E3DFD7"
dorado     <- "#C5A46D"

Grafico_exactitud_k <- plot_ly(resultado_k, x = ~k) %>%
  add_trace(
    y = ~exactitud,
    type = "scatter",
    mode = "lines+markers",
    line = list(color = rojo, width = 2.5),
    marker = list(size = 7, color = rojo,
                  line = list(color = "white", width = 1)),
    text = ~paste0(
      "<b>k = ", k, "</b><br>",
      "Exactitud: ", scales::percent(exactitud, accuracy = 0.1, decimal.mark = ","), "<br>",
      "Valor exacto: ", round(exactitud, 4)
    ),
    hoverinfo = "text",
    name = "Exactitud"
  ) %>%
  add_segments(
    x = k_optimo, xend = k_optimo,
    y = min(resultado_k$exactitud) - 0.01, yend = exac_optima,
    line = list(color = dorado, width = 2.5, dash = "dash"),
    name = paste0("k óptimo = ", k_optimo),
    hoverinfo = "none"
  ) %>%
  layout(
    title = list(
      text = "<b>Figura 10. Exactitud del modelo KNN según el número de vecinos (k)</b>",
      font = list(family = "Playfair Display", size = 20, color = rojo),
      x = 0.05
    ),
    xaxis = list(
      title = list(text = "Número de vecinos (k)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      gridcolor = rojo_claro,
      dtick = 10,
      range = c(0, max(k_vals) + 2)
    ),
    yaxis = list(
      title = list(text = "Exactitud (validación cruzada)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      gridcolor = rojo_claro,
      tickformat = ".1%",
      range = c(min(resultado_k$exactitud) - 0.01, max(resultado_k$exactitud) + 0.01)
    ),
    plot_bgcolor = "#F5F5F5",
    paper_bgcolor = "white",
    font = list(family = "Source Serif Pro", size = 12),
    margin = list(l = 80, r = 40, t = 90, b = 100),
    hovermode = "closest",
    hoverdistance = 40,
    hoverlabel = list(bgcolor = "white", bordercolor = rojo_osc,
                      font = list(family = "Source Serif Pro")),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.22),
    annotations = list(
      list(
        x = k_optimo, y = exac_optima,
        text = paste0("<b>k óptimo = ", k_optimo, "</b>"),
        showarrow = TRUE, arrowcolor = rojo_osc, ax = 0, ay = -45,
        font = list(family = "Playfair Display", size = 13, color = rojo_osc),
        bgcolor = dorado, bordercolor = rojo_osc, borderwidth = 1.5, borderpad = 4
      ),
      list(
        x = 1, y = -0.28, xref = "paper", yref = "paper",
        text = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        showarrow = FALSE, xanchor = "right",
        font = list(family = "Source Serif Pro", size = 10, color = "#858585")
      )
    )
  ) %>%
  config(
    displaylogo = FALSE,
    modeBarButtonsToRemove = c("pan2d", "lasso2d", "select2d"),
    toImageButtonOptions = list(format = "png", filename = "exactitud_knn",
                                width = 1000, height = 600)
  )

Grafico_exactitud_k

Luego de analizar la figura se seleccionó k = 41, valor con el cual el modelo alcanza la mayor exactitud promedio en la validación cruzada (72,6%). Con este valor se ajustó el modelo final sobre todo el conjunto de entrenamiento y se clasificaron las observaciones del conjunto de prueba. Además de la categoría predicha, se conservó la proporción de votos de los vecinos a favor de la categoría Alto, que se utiliza como puntuación para calcular el área bajo la curva ROC (AUC).

A continuación se reportan la matriz de confusión y los indicadores de desempeño del modelo KNN, tomando la categoría Alto como clase positiva.

set.seed(28)

pred_knn_raw <- class::knn(
  train = x_train,
  test  = x_test,
  cl    = y_train,
  k     = k_optimo,
  prob  = TRUE
)

pred_knn <- factor(pred_knn_raw, levels = c("Bajo", "Alto"))

# Proporción de votos a favor de "Alto" (puntuación para la curva ROC)
prop_ganadora <- attr(pred_knn_raw, "prob")
p_knn <- ifelse(pred_knn == "Alto", prop_ganadora, 1 - prop_ganadora)

cm_knn  <- confusionMatrix(pred_knn, y_test, positive = "Alto")
roc_knn <- roc(response = y_test, predictor = p_knn,
               levels = c("Bajo", "Alto"), direction = "<")
auc_knn <- as.numeric(auc(roc_knn))

matriz_cm <- as.data.frame.matrix(cm_knn$table)
tabla_cm  <- data.frame(`Predicción` = rownames(matriz_cm), matriz_cm,
                        check.names = FALSE, row.names = NULL)

kbl(
  tabla_cm,
  format = "html",
  caption = "Tabla 9. Matriz de confusión del modelo KNN",
  align = c("l", "c", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  add_header_above(
    c(" " = 1, "Valor real" = 2),
    bold = TRUE,
    background = "#171717",
    color = "#E0CCA0",
    extra_css = "border-bottom: 2px solid #C5A46D;"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D;"
  ) %>%
  row_spec(
    1,
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    2,
    background = "#FFFFFF",
    color = "#292929"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    width = "3cm",
    background = "#F0F0F0",
    color = "#292929"
  ) %>%
  column_spec(
    2:3,
    width = "2.5cm",
    color = "#333333"
  ) %>%
  footnote(
    general = paste0(
      "Los valores de la diagonal representan las clasificaciones correctas. ",
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025."
    ),
    general_title = "Nota:",
    footnote_as_chunk = TRUE
  )
Tabla 9. Matriz de confusión del modelo KNN
Valor real
Predicción Bajo Alto
Bajo 1385 591
Alto 352 1143
Nota: Los valores de la diagonal representan las clasificaciones correctas. Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.
metricas_knn <- 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",
    "Prevalencia",
    "Tasa de detección",
    "Prevalencia de detección",
    "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",
    "Evalúa la simetría entre falsos positivos y falsos negativos",
    "Capacidad de detectar ingresos altos (VP / VP + FN)",
    "Capacidad de detectar ingresos bajos (VN / VN + FP)",
    "Precisión para la clase 'Alto' (VP / VP + FP)",
    "Precisión para la clase 'Bajo' (VN / VN + FN)",
    "Proporción real de trabajadores con ingreso alto",
    "Proporción total correctamente identificada como ingreso alto",
    "Proporción de trabajadores predichos como ingreso alto",
    "Promedio entre sensibilidad y especificidad",
    "Capacidad de discriminación a partir de la proporción de votos"
  ),
  Valor = c(
    f4(cm_knn$overall[["Accuracy"]]),
    paste0(
      "(", f4(cm_knn$overall[["AccuracyLower"]]), "; ",
      f4(cm_knn$overall[["AccuracyUpper"]]), ")"
    ),
    f4(cm_knn$overall[["AccuracyNull"]]),
    formatC(
      cm_knn$overall[["AccuracyPValue"]],
      format = "e", digits = 2, decimal.mark = ","
    ),
    f4(cm_knn$overall[["Kappa"]]),
    formatC(
      cm_knn$overall[["McnemarPValue"]],
      format = "e", digits = 2, decimal.mark = ","
    ),
    f4(cm_knn$byClass[["Sensitivity"]]),
    f4(cm_knn$byClass[["Specificity"]]),
    f4(cm_knn$byClass[["Pos Pred Value"]]),
    f4(cm_knn$byClass[["Neg Pred Value"]]),
    f4(cm_knn$byClass[["Prevalence"]]),
    f4(cm_knn$byClass[["Detection Rate"]]),
    f4(cm_knn$byClass[["Detection Prevalence"]]),
    f4(cm_knn$byClass[["Balanced Accuracy"]]),
    f4(auc_knn)
  )
)

kbl(
  metricas_knn,
  format = "html",
  caption = "Tabla 10. Métricas de evaluación del modelo KNN",
  align = c("l", "l", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3, 5, 7, 9, 11, 13, 15),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4, 6, 8, 10, 12, 14),
    background = "#FFFFFF",
    color = "#333333"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    color = "#292929",
    width = "5cm"
  ) %>%
  column_spec(
    2,
    color = "#444444",
    width = "8cm"
  ) %>%
  column_spec(
    3,
    bold = TRUE,
    color = "#292929",
    width = "3cm"
  ) %>%
  footnote(
    general = paste0(
      "VP = verdadero positivo, VN = verdadero negativo, ",
      "FP = falso positivo, FN = falso negativo. ",
      "La clase positiva es 'Alto'. ",
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 10. Métricas de evaluación del modelo KNN
Métrica Descripción Valor
Exactitud (Accuracy) Proporción total de predicciones correctas 0,7283
Intervalo de confianza 95 % Intervalo de confianza para la exactitud (0,7132; 0,7431)
Tasa de no información Exactitud al predecir siempre la clase mayoritaria 0,5004
Valor p [Exactitud > TNI] Significancia estadística frente a la tasa base 6,88e-165
Kappa de Cohen Acuerdo entre lo observado y lo predicho, ajustado por azar 0,4566
Valor p de McNemar Evalúa la simetría entre falsos positivos y falsos negativos 9,16e-15
Sensibilidad (Recall) Capacidad de detectar ingresos altos (VP / VP + FN) 0,6592
Especificidad Capacidad de detectar ingresos bajos (VN / VN + FP) 0,7974
Valor predictivo positivo (Precisión) Precisión para la clase ‘Alto’ (VP / VP + FP) 0,7645
Valor predictivo negativo Precisión para la clase ‘Bajo’ (VN / VN + FN) 0,7009
Prevalencia Proporción real de trabajadores con ingreso alto 0,4996
Tasa de detección Proporción total correctamente identificada como ingreso alto 0,3293
Prevalencia de detección Proporción de trabajadores predichos como ingreso alto 0,4307
Exactitud balanceada Promedio entre sensibilidad y especificidad 0,7283
AUC Capacidad de discriminación a partir de la proporción de votos 0,8010
Nota y fuente: VP = verdadero positivo, VN = verdadero negativo, FP = falso positivo, FN = falso negativo. La clase positiva es ‘Alto’. Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.

Con k = 41, el modelo KNN clasificó correctamente el 72,8 % de las observaciones del conjunto de prueba (IC 95 %: 71,3 %–74,3 %). La mejora de más de 22 puntos porcentuales es estadísticamente significativa (p < 0,001), por lo que el desempeño del modelo no puede atribuirse al azar ni a la simple composición de las clases.

Sin embargo, la exactitud por sí sola puede ser engañosa, por lo que se complementa con el coeficiente Kappa de Cohen, que mide el acuerdo entre las clases observadas y las predichas descontando el acuerdo esperado por azar. El modelo obtuvo un Kappa de 0,4566. De acuerdo con la escala de Landis y Koch (1977), valores entre 0,41 y 0,60 corresponden a un acuerdo moderado. No existe un umbral mínimo universal para este indicador, pero la regla más aceptada considera un acuerdo bueno o sustancial a partir de 0,61, y en contextos críticos (como aplicaciones médicas o de seguridad financiera) se suele exigir más de 0,80. Nuestro resultado, por tanto, queda por debajo del estándar de un acuerdo sustancial. Este resultado no se explica por un desbalance de clases, que suele deprimir el Kappa: la prevalencia de ingreso alto es de 49,96 %, prácticamente balanceada. El valor moderado refleja, entonces, una limitación real del modelo para separar a los trabajadores de ingreso alto y bajo con las variables disponibles. Dado que el objetivo es analítico y no una decisión de alto riesgo, este nivel de acuerdo es aceptable como punto de partida, aunque deja margen de mejora.

El desempeño por clase muestra un comportamiento asimétrico. La especificidad (79,7 %) es notablemente mayor que la sensibilidad (65,9 %): el modelo identifica con más facilidad a los trabajadores de ingreso bajo que a los de ingreso alto, y deja sin detectar a cerca de uno de cada tres trabajadores de ingreso alto. Esta asimetría es consistente con la prevalencia de detección (43,1 %), inferior a la prevalencia real (50,0 %), lo que indica que el modelo tiende a subestimar la clase “Alto”. La prueba de McNemar (p < 0,001) confirma que los falsos positivos y los falsos negativos no están distribuidos de forma simétrica, de modo que este sesgo no es producto del azar.

En términos de precisión de las predicciones, el valor predictivo positivo (76,5 %) indica que, cuando el modelo clasifica a un trabajador como de ingreso alto, acierta en tres de cada cuatro casos, mientras que el valor predictivo negativo (70,1 %) es algo menor. La exactitud balanceada (72,8 %), que promedia sensibilidad y especificidad, coincide con la exactitud global, como es de esperar en un conjunto de prueba casi balanceado. Por último, el AUC de 0,80 señala una buena capacidad de discriminación: si se escogen al azar un trabajador de ingreso alto y uno de ingreso bajo, el modelo asigna una mayor proporción de votos a la clase “Alto” en el primero en el 80 % de los casos. En conjunto, el KNN ofrece un desempeño sólido y significativamente superior a la clasificación ingenua, aunque con un acuerdo moderado una vez descontado el azar y una menor capacidad para detectar ingresos altos, aspectos que se contrastarán con el modelo logit en la sección de comparación.

4.3 Modelo Logit

La regresión logística binaria es un modelo estadístico que estima la probabilidad de que una observación pertenezca a una de dos categorías a partir de un conjunto de variables explicativas. En este trabajo se utiliza para estimar la probabilidad de que un trabajador pertenezca al grupo de ingreso alto. A diferencia de KNN, el Logit es un modelo paramétrico e interpretable: sus coeficientes describen cómo se asocia cada variable con la probabilidad de ingreso alto, manteniendo constantes las demás. Estas interpretaciones corresponden a asociaciones estadísticas y no implican una relación causal.

El modelo se estimó con la función glm() de R, utilizando la familia binomial y únicamente el conjunto de entrenamiento. La variable dependiente se codificó como 1 para ingreso alto y 0 para ingreso bajo. Para facilitar la lectura de los resultados se fijaron como categorías de referencia Hombre, Primaria y Particular, de modo que los coeficientes de las demás categorías se interpretan frente a ellas.

Una vez ajustado el modelo, se calcularon las probabilidades estimadas para el conjunto de prueba. Para convertirlas en predicciones de clase se utilizó un umbral óptimo, definido mediante el índice de Youden extraído de la curva ROC. Este umbral se calculó con las probabilidades del conjunto de entrenamiento, de modo que el conjunto de prueba se conserva como evaluación final independiente.

preparar_logit <- function(d) {
  d %>%
    mutate(
      ingreso_num     = as.numeric(ingreso_f == "Alto"),
      sexo            = relevel(sexo, ref = "Hombre"),
      nivel_educativo = relevel(nivel_educativo, ref = "Primaria"),
      tipo_empleado   = relevel(tipo_empleado, ref = "Particular")
    )
}

train_logit <- preparar_logit(train)
test_logit  <- preparar_logit(test)


fit_logit <- glm(
  ingreso_num ~ sexo + edad + nivel_educativo + horas_trabajadas + tipo_empleado,
  data   = train_logit,
  family = binomial()
)


p_train <- predict(fit_logit, newdata = train_logit, type = "response")
p_logit <- predict(fit_logit, newdata = test_logit,  type = "response")


roc_train <- roc(response = train_logit$ingreso_f, predictor = p_train,
                 levels = c("Bajo", "Alto"), direction = "<")
umbral <- as.numeric(unlist(
  coords(roc_train, x = "best", best.method = "youden", ret = "threshold")
))[1]


pred_logit <- factor(ifelse(p_logit >= umbral, "Alto", "Bajo"),
                     levels = c("Bajo", "Alto"))


cm_logit  <- confusionMatrix(pred_logit, test_logit$ingreso_f, positive = "Alto")
roc_logit <- roc(response = test_logit$ingreso_f, predictor = p_logit,
                 levels = c("Bajo", "Alto"), direction = "<")
auc_logit <- as.numeric(auc(roc_logit))

cat("Umbral óptimo (Youden, entrenamiento):", round(umbral, 4),
    "| AUC en prueba:", round(auc_logit, 4), "\n")
## Umbral óptimo (Youden, entrenamiento): 0.5026 | AUC en prueba: 0.8044

La probabilidad de pertenecer al grupo de ingreso alto se expresa mediante la función logística:

\[P(\text{Ingreso alto} = 1 \mid \mathbf{X}) = \frac{1}{1 + e^{-z}}\]

donde:

\[z = \beta_0 + \beta_1 \cdot \text{Sexo} + \beta_2 \cdot \text{Edad} + \beta_3 \cdot \text{Nivel educativo} + \beta_4 \cdot \text{Horas trabajadas} + \beta_5 \cdot \text{Tipo de empleado}\]

Las variables categóricas (sexo, nivel educativo y tipo de empleado) entran al modelo como variables indicadoras, una por cada categoría distinta de la de referencia. La siguiente tabla presenta los coeficientes estimados, su significancia estadística y las razones de posibilidades (odds ratios), obtenidas como la exponencial de cada coeficiente.

coefs <- summary(fit_logit)$coefficients
ic    <- confint.default(fit_logit)   # intervalos aproximados (Wald)

res_or <- data.frame(
  nombre = rownames(coefs),
  beta   = coefs[, "Estimate"],
  ee     = coefs[, "Std. Error"],
  z      = coefs[, "z value"],
  p      = coefs[, "Pr(>|z|)"],
  OR     = exp(coefs[, "Estimate"]),
  lo     = exp(ic[, 1]),
  hi     = exp(ic[, 2]),
  row.names = NULL
)

# Etiquetas legibles para los coeficientes
etiquetas <- c(
  "(Intercept)"                       = "Intercepto",
  "sexoMujer"                         = "Sexo: Mujer (ref.: Hombre)",
  "edad"                              = "Edad (años)",
  "nivel_educativoPostgrado"          = "Nivel educativo: Postgrado (ref.: Primaria)",
  "nivel_educativoSecundaria"         = "Nivel educativo: Secundaria (ref.: Primaria)",
  "nivel_educativoTécnica/Tecnología" = "Nivel educativo: Técnica/Tecnología (ref.: Primaria)",
  "nivel_educativoUniversitaria"      = "Nivel educativo: Universitaria (ref.: Primaria)",
  "horas_trabajadas"                  = "Horas trabajadas por semana",
  "tipo_empleadoGobierno"             = "Tipo de empleado: Gobierno (ref.: Particular)"
)

res_or$etiqueta <- etiquetas[res_or$nombre]
res_or$etiqueta[is.na(res_or$etiqueta)] <- res_or$nombre[is.na(res_or$etiqueta)]

res_or$signif <- case_when(
  res_or$p < 0.001 ~ "***",
  res_or$p < 0.01  ~ "**",
  res_or$p < 0.05  ~ "*",
  res_or$p < 0.1   ~ ".",
  TRUE             ~ ""
)

tabla_logit <- data.frame(
  Variable             = res_or$etiqueta,
  Coeficiente          = f4(res_or$beta),
  `Error estándar`     = f4(res_or$ee),
  `Estadístico z`      = f4(res_or$z),
  `Valor p`            = ifelse(res_or$p < 0.001, "<0,001", f4(res_or$p)),
  `Signif.`            = res_or$signif,
  `OR (e^β)`           = f4(res_or$OR),
  `IC 95 % del OR`     = paste0("(", f4(res_or$lo), "; ", f4(res_or$hi), ")"),
  check.names = FALSE
)

kbl(
  tabla_logit,
  format = "html",
  caption = "Tabla 11. Resultados del modelo de regresión logística (Logit)",
  align = c("l", "r", "r", "r", "r", "c", "r", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3, 5, 7, 9),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4, 6, 8),
    background = "#FFFFFF",
    color = "#333333",
    extra_css = "border-bottom: 1px solid #E8E8E8;"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    color = "#292929",
    width = "6.5cm"
  ) %>%
  column_spec(
    2:5,
    color = "#333333"
  ) %>%
  column_spec(
    6,
    bold = TRUE,
    color = "#C5A46D",
    width = "1.2cm"
  ) %>%
  column_spec(
    7:8,
    color = "#333333"
  ) %>%
  footnote(
    general = paste0(
      "Elaboración propia con base en la GEIH del DANE, junio de 2025. ",
      "Los valores de OR (e^β) indican el cambio multiplicativo en las posibilidades de ingreso alto por cada unidad adicional (variables cuantitativas) o frente a la categoría de referencia (variables categóricas). ",
      "Significancia: *** p<0,001, ** p<0,01, * p<0,05, . p<0,1. ",
      "Las filas sombreadas corresponden a la alternancia visual de la tabla."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 11. Resultados del modelo de regresión logística (Logit)
Variable Coeficiente Error estándar Estadístico z Valor p Signif. OR (e^β) IC 95 % del OR
Intercepto -3,9987 0,1819 -21,9891 <0,001 *** 0,0183 (0,0128; 0,0262)
Sexo: Mujer (ref.: Hombre) -0,4983 0,0483 -10,3128 <0,001 *** 0,6075 (0,5526; 0,6679)
Edad (años) 0,0225 0,0020 11,3404 <0,001 *** 1,0228 (1,0188; 1,0268)
Nivel educativo: Postgrado (ref.: Primaria) 4,9905 0,2647 18,8512 <0,001 *** 147,0132 (87,5016; 246,9996)
Nivel educativo: Secundaria (ref.: Primaria) 0,6867 0,0729 9,4137 <0,001 *** 1,9871 (1,7224; 2,2924)
Nivel educativo: Técnica/Tecnología (ref.: Primaria) 1,4543 0,0817 17,8081 <0,001 *** 4,2815 (3,6482; 5,0247)
Nivel educativo: Universitaria (ref.: Primaria) 2,4118 0,0853 28,2874 <0,001 *** 11,1535 (9,4371; 13,1820)
Horas trabajadas por semana 0,0409 0,0029 14,1682 <0,001 *** 1,0417 (1,0358; 1,0476)
Tipo de empleado: Gobierno (ref.: Particular) 2,6695 0,1663 16,0512 <0,001 *** 14,4333 (10,4183; 19,9955)
Nota y fuente: Elaboración propia con base en la GEIH del DANE, junio de 2025. Los valores de OR (e^β) indican el cambio multiplicativo en las posibilidades de ingreso alto por cada unidad adicional (variables cuantitativas) o frente a la categoría de referencia (variables categóricas). Significancia: *** p<0,001, ** p<0,01, * p<0,05, . p<0,1. Las filas sombreadas corresponden a la alternancia visual de la tabla.

De los 8 coeficientes asociados a las variables explicativas, 8 resultaron estadísticamente significativos al 5 %. Entre ellos, tienen signo positivo (se asocian con mayores posibilidades de ingreso alto): Edad (años); Nivel educativo: Postgrado (ref.: Primaria); Nivel educativo: Secundaria (ref.: Primaria); Nivel educativo: Técnica/Tecnología (ref.: Primaria); Nivel educativo: Universitaria (ref.: Primaria); Horas trabajadas por semana; Tipo de empleado: Gobierno (ref.: Particular). Tienen signo negativo (se asocian con menores posibilidades): Sexo: Mujer (ref.: Hombre). Los odds ratios de la tabla permiten una lectura directa: un valor mayor que 1 indica un aumento multiplicativo de las posibilidades de ingreso alto, y uno menor que 1, una disminución.

Respecto al ajuste global, la deviance residual del modelo (11134,0) es menor que la deviance nula (14436,9), lo que refleja el aporte conjunto de las variables explicativas frente a un modelo sin predictores. El AIC del modelo es 11152,0. Estos indicadores describen el ajuste dentro de la muestra de entrenamiento; la capacidad de clasificación se evalúa más adelante con el conjunto de prueba.

En la regresión logística, el umbral de corte (cutoff) es el valor a partir del cual las probabilidades estimadas se convierten en predicciones de clase. Aunque el valor usual es 0,5, no siempre resulta óptimo, ya que según el problema puede interesar equilibrar la detección de los trabajadores de ingreso alto (sensibilidad) con la de ingreso bajo (especificidad). Como en esta base las dos categorías están prácticamente equilibradas, se espera que el umbral óptimo quede cerca de 0,5.

Para determinarlo objetivamente se empleó el índice de Youden, que busca el punto de la curva ROC que maximiza la suma de sensibilidad y especificidad. La siguiente figura presenta la curva ROC del modelo sobre el conjunto de prueba, con el punto que corresponde al umbral seleccionado. Es interactiva: al pasar el cursor sobre la curva se muestran la tasa de falsos positivos y la sensibilidad.

rojo       <- "#171717"
rojo_osc   <- "#4A443A"
rojo_claro <- "#E3DFD7"
dorado     <- "#C5A46D"

roc_data <- data.frame(
  FPR          = 1 - roc_logit$specificities,
  Sensibilidad = roc_logit$sensitivities
) %>%
  arrange(FPR, Sensibilidad)

sens_opt <- cm_logit$byClass[["Sensitivity"]]
fpr_opt  <- 1 - cm_logit$byClass[["Specificity"]]

Curva_ROC_Logit <- plot_ly() %>%
  add_trace(
    data = roc_data, x = ~FPR, y = ~Sensibilidad,
    type = "scatter", mode = "lines",
    fill = "tozeroy", fillcolor = "rgba(23,23,23,0.08)",
    line = list(color = rojo_osc, width = 3),
    name = "Curva ROC",
    hoverinfo = "text",
    text = ~paste0("1 - Especificidad: ", round(FPR, 3),
                   "<br>Sensibilidad: ", round(Sensibilidad, 3))
  ) %>%
  add_trace(
    x = c(0, 1), y = c(0, 1),
    type = "scatter", mode = "lines",
    line = list(color = "grey", dash = "dash", width = 1.5),
    name = "Línea de referencia (azar)",
    hoverinfo = "none"
  ) %>%
  add_trace(
    x = fpr_opt, y = sens_opt,
    type = "scatter", mode = "markers",
    marker = list(size = 13, color = dorado,
                  line = list(color = rojo_osc, width = 2)),
    name = paste0("Umbral óptimo = ", round(umbral, 3)),
    hoverinfo = "text",
    text = paste0("<b>Umbral óptimo = ", round(umbral, 3), "</b>",
                  "<br>1 - Especificidad: ", round(fpr_opt, 3),
                  "<br>Sensibilidad: ", round(sens_opt, 3))
  ) %>%
  layout(
    title = list(
      text = "<b>Figura 11. Curva ROC del modelo Logit</b>",
      font = list(family = "Playfair Display", size = 20, color = rojo),
      x = 0.05
    ),
    xaxis = list(
      title = list(text = "1 - Especificidad (tasa de falsos positivos)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.2, tickformat = ".1f",
      gridcolor = rojo_claro
    ),
    yaxis = list(
      title = list(text = "Sensibilidad (tasa de verdaderos positivos)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.2, tickformat = ".1f",
      gridcolor = rojo_claro
    ),
    plot_bgcolor = "#F5F5F5",
    paper_bgcolor = "white",
    font = list(family = "Source Serif Pro", size = 12),
    margin = list(l = 80, r = 40, t = 90, b = 110),
    hoverlabel = list(bgcolor = "white", bordercolor = rojo_osc,
                      font = list(family = "Source Serif Pro")),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.2),
    annotations = list(
      list(
        x = 0.75, y = 0.15, text = paste0("<b>AUC = ", round(auc_logit, 3), "</b>"),
        showarrow = FALSE,
        font = list(family = "Playfair Display", size = 16, color = rojo_osc),
        bgcolor = "rgba(255,255,255,0.85)", bordercolor = rojo_osc,
        borderwidth = 1, borderpad = 5
      ),
      list(
        x = 1, y = -0.3, xref = "paper", yref = "paper",
        text = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        showarrow = FALSE, xanchor = "right",
        font = list(family = "Source Serif Pro", size = 10, color = "#858585")
      )
    )
  ) %>%
  config(
    displaylogo = FALSE,
    modeBarButtonsToRemove = c("pan2d", "lasso2d", "select2d"),
    toImageButtonOptions = list(format = "png", filename = "roc_logit",
                                width = 1000, height = 700)
  )

Curva_ROC_Logit

El umbral óptimo seleccionado fue aproximadamente 0,503. Con este valor, las predicciones del modelo buscan equilibrar la identificación de los trabajadores de ingreso alto y la de ingreso bajo.

El área bajo la curva (AUC) en el conjunto de prueba es 0,8044, lo que indica una capacidad discriminativa muy buena para distinguir entre ambas categorías de ingreso. Un AUC de 0,5 equivale a un modelo sin capacidad discriminativa (aleatorio), mientras que un AUC de 1 indica discriminación perfecta.

La siguiente figura muestra cómo varían la sensibilidad, la especificidad, la precisión y la exactitud del modelo según el umbral de decisión, lo que permite ver el umbral óptimo y su efecto sobre el desempeño del clasificador. Es interactiva: al pasar el cursor sobre cada línea se muestran la métrica, el umbral y el valor.

cortes <- seq(0.05, 0.95, length.out = 91)
y_real <- test_logit$ingreso_f == "Alto"

res_umbral <- t(sapply(cortes, function(co) {
  pred <- p_logit >= co
  tp <- sum(pred & y_real);  tn <- sum(!pred & !y_real)
  fp <- sum(pred & !y_real); fn <- sum(!pred & y_real)
  c(Sensibilidad  = tp / (tp + fn),
    Especificidad = tn / (tn + fp),
    Precision     = ifelse(tp + fp == 0, NA, tp / (tp + fp)),
    Exactitud     = (tp + tn) / length(y_real))
}))

res_umbral <- as.data.frame(res_umbral) %>%
  mutate(Umbral = cortes) %>%
  pivot_longer(cols = -Umbral, names_to = "Metrica", values_to = "Valor") %>%
  mutate(Metrica = recode(Metrica, Precision = "Precisión"))

colores_metricas <- c("Sensibilidad" = rojo, "Especificidad" = "#858585",
                      "Precisión" = "#A88951", "Exactitud" = dorado)

Grafico_umbral_logit <- plot_ly() %>%
  add_trace(
    data = res_umbral, x = ~Umbral, y = ~Valor, color = ~Metrica,
    colors = colores_metricas,
    type = "scatter", mode = "lines",
    line = list(width = 3),
    hoverinfo = "text",
    text = ~paste0("<b>", Metrica, "</b>",
                   "<br>Umbral: ", round(Umbral, 3),
                   "<br>Valor: ", round(Valor, 3))
  ) %>%
  add_segments(
    x = umbral, xend = umbral, y = 0, yend = 1,
    line = list(color = rojo_osc, dash = "dash", width = 2),
    name = paste0("Umbral óptimo = ", round(umbral, 3)),
    hoverinfo = "none"
  ) %>%
  add_segments(
    x = 0.5, xend = 0.5, y = 0, yend = 1,
    line = list(color = "grey", dash = "dot", width = 1.5),
    name = "Umbral 0,5",
    hoverinfo = "none"
  ) %>%
  layout(
    title = list(
      text = "<b>Figura 12. Desempeño del modelo Logit según el umbral de decisión</b>",
      font = list(family = "Playfair Display", size = 18, color = rojo),
      x = 0.05
    ),
    xaxis = list(
      title = list(text = "Umbral de decisión",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0.05, 0.95), dtick = 0.1, gridcolor = rojo_claro
    ),
    yaxis = list(
      title = list(text = "Valor de la métrica",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.1, gridcolor = rojo_claro
    ),
    plot_bgcolor = "#F5F5F5",
    paper_bgcolor = "white",
    font = list(family = "Source Serif Pro", size = 12),
    margin = list(l = 80, r = 40, t = 90, b = 120),
    hoverlabel = list(bgcolor = "white", bordercolor = rojo_osc,
                      font = list(family = "Source Serif Pro")),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.22),
    annotations = list(
      list(
        x = 1, y = -0.34, xref = "paper", yref = "paper",
        text = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
        showarrow = FALSE, xanchor = "right",
        font = list(family = "Source Serif Pro", size = 10, color = "#858585")
      )
    )
  ) %>%
  config(
    displaylogo = FALSE,
    modeBarButtonsToRemove = c("pan2d", "lasso2d", "select2d"),
    toImageButtonOptions = list(format = "png", filename = "umbral_logit",
                                width = 1000, height = 600)
  )

Grafico_umbral_logit

Posteriormente, con las predicciones del conjunto de prueba, se construye la matriz de confusión, que permite evaluar el desempeño del modelo en términos de clasificaciones correctas e incorrectas. Además, se calculan las métricas que describen la eficacia del modelo para detectar a los trabajadores de ingreso alto (clase positiva) y de ingreso bajo.

matriz_cm_logit <- as.data.frame.matrix(cm_logit$table)
tabla_cm_logit  <- data.frame(`Predicción` = rownames(matriz_cm_logit), matriz_cm_logit,
                              check.names = FALSE, row.names = NULL)

kbl(
  tabla_cm_logit,
  format = "html",
  caption = "Tabla 12. Matriz de confusión del modelo Logit (con umbral óptimo)",
  align = c("l", "c", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  add_header_above(
    c(" " = 1, "Valor real" = 2),
    bold = TRUE,
    background = "#171717",
    color = "#E0CCA0",
    extra_css = "border-bottom: 2px solid #C5A46D;"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    1,
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    2,
    background = "#FFFFFF",
    color = "#292929"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    width = "3cm",
    background = "#F0F0F0",
    color = "#292929"
  ) %>%
  column_spec(
    2:3,
    width = "2.5cm",
    color = "#333333"
  ) %>%
  footnote(
    general = paste0(
      "La matriz presenta las predicciones frente a los valores reales; ",
      "la clase positiva es 'Alto'. Los valores de la diagonal son las ",
      "clasificaciones correctas. ",
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 12. Matriz de confusión del modelo Logit (con umbral óptimo)
Valor real
Predicción Bajo Alto
Bajo 1393 595
Alto 344 1139
Nota y fuente: La matriz presenta las predicciones frente a los valores reales; la clase positiva es ‘Alto’. Los valores de la diagonal son las clasificaciones correctas. Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.
acc      <- cm_logit$overall[["Accuracy"]]
acc_lo   <- cm_logit$overall[["AccuracyLower"]]
acc_hi   <- cm_logit$overall[["AccuracyUpper"]]
nir      <- cm_logit$overall[["AccuracyNull"]]
p_acc    <- cm_logit$overall[["AccuracyPValue"]]
kappa    <- cm_logit$overall[["Kappa"]]
p_mcn    <- cm_logit$overall[["McnemarPValue"]]
sens     <- cm_logit$byClass[["Sensitivity"]]
espec    <- cm_logit$byClass[["Specificity"]]
vpp      <- cm_logit$byClass[["Pos Pred Value"]]
vpn      <- cm_logit$byClass[["Neg Pred Value"]]
prev     <- cm_logit$byClass[["Prevalence"]]
det_rate <- cm_logit$byClass[["Detection Rate"]]
det_prev <- cm_logit$byClass[["Detection Prevalence"]]
bal      <- cm_logit$byClass[["Balanced Accuracy"]]

metricas_logit <- 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",
    "Prevalencia",
    "Tasa de detección",
    "Prevalencia de detección",
    "Exactitud balanceada",
    "AUC"
  ),
  Valor = c(
    f4(acc),
    paste0("(", f4(acc_lo), "; ", f4(acc_hi), ")"),
    f4(nir),
    formatC(p_acc, format = "e", digits = 2, decimal.mark = ","),
    f4(kappa),
    formatC(p_mcn, format = "e", digits = 2, decimal.mark = ","),
    f4(sens),
    f4(espec),
    f4(vpp),
    f4(vpn),
    f4(prev),
    f4(det_rate),
    f4(det_prev),
    f4(bal),
    f4(auc_logit)
  ),
  Interpretación = c(
    paste0("El modelo clasifica correctamente el ", pct(acc), " de los trabajadores del conjunto de prueba."),
    paste0("La exactitud verdadera se ubicaría entre ", pct(acc_lo), " y ", pct(acc_hi), ", con una confianza del 95 %."),
    paste0("Un modelo trivial que siempre prediga la clase más frecuente acertaría el ", pct(nir), " de los casos."),
    ifelse(p_acc < 0.05,
           "La exactitud es significativamente mayor que la del modelo trivial (p < 0,05).",
           "No hay evidencia de que la exactitud supere la del modelo trivial (p ≥ 0,05)."),
    paste0("Concordancia ", calif_kappa(kappa), " entre las predicciones y las categorías observadas, ajustada por azar."),
    ifelse(p_mcn < 0.05,
           "Hay evidencia de asimetría entre los errores de ambas clases (p < 0,05).",
           "No hay evidencia de asimetría entre los errores de ambas clases (p ≥ 0,05)."),
    paste0("El modelo identifica correctamente el ", pct(sens), " de los trabajadores de ingreso alto."),
    paste0("El modelo identifica correctamente el ", pct(espec), " de los trabajadores de ingreso bajo."),
    paste0("Cuando el modelo predice ingreso alto, acierta en el ", pct(vpp), " de los casos."),
    paste0("Cuando el modelo predice ingreso bajo, acierta en el ", pct(vpn), " de los casos."),
    paste0("El ", pct(prev), " de los trabajadores del conjunto de prueba tiene ingreso alto."),
    paste0("El modelo detecta correctamente como ingreso alto al ", pct(det_rate), " del total de trabajadores."),
    paste0("El modelo predice ingreso alto para el ", pct(det_prev), " de los trabajadores."),
    paste0("Promedio entre sensibilidad y especificidad: ", pct(bal), "."),
    paste0("Capacidad de discriminación ", calif_auc(auc_logit), " a partir de las probabilidades estimadas.")
  )
)

kbl(
  metricas_logit,
  format = "html",
  caption = "Tabla 13. Métricas de evaluación del modelo Logit (con umbral óptimo)",
  align = c("l", "c", "l"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3, 5, 7, 9, 11, 13, 15),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4, 6, 8, 10, 12, 14),
    background = "#FFFFFF",
    color = "#333333"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    color = "#292929",
    width = "4.5cm"
  ) %>%
  column_spec(
    2,
    bold = TRUE,
    color = "#292929",
    width = "3cm"
  ) %>%
  column_spec(
    3,
    color = "#444444",
    width = "10cm"
  ) %>%
  footnote(
    general = paste0(
      "La clase positiva es 'Alto'. ",
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 13. Métricas de evaluación del modelo Logit (con umbral óptimo)
Métrica Valor Interpretación
Exactitud (Accuracy) 0,7295 El modelo clasifica correctamente el 72,9% de los trabajadores del conjunto de prueba.
Intervalo de confianza 95 % (0,7144; 0,7442) La exactitud verdadera se ubicaría entre 71,4% y 74,4%, con una confianza del 95 %.
Tasa de no información 0,5004 Un modelo trivial que siempre prediga la clase más frecuente acertaría el 50,0% de los casos.
Valor p [Exactitud > TNI] 1,32e-166 La exactitud es significativamente mayor que la del modelo trivial (p < 0,05).
Kappa de Cohen 0,4589 Concordancia moderada entre las predicciones y las categorías observadas, ajustada por azar.
Valor p de McNemar 3,39e-16 Hay evidencia de asimetría entre los errores de ambas clases (p < 0,05).
Sensibilidad (Recall) 0,6569 El modelo identifica correctamente el 65,7% de los trabajadores de ingreso alto.
Especificidad 0,8020 El modelo identifica correctamente el 80,2% de los trabajadores de ingreso bajo.
Valor predictivo positivo (Precisión) 0,7680 Cuando el modelo predice ingreso alto, acierta en el 76,8% de los casos.
Valor predictivo negativo 0,7007 Cuando el modelo predice ingreso bajo, acierta en el 70,1% de los casos.
Prevalencia 0,4996 El 50,0% de los trabajadores del conjunto de prueba tiene ingreso alto.
Tasa de detección 0,3281 El modelo detecta correctamente como ingreso alto al 32,8% del total de trabajadores.
Prevalencia de detección 0,4273 El modelo predice ingreso alto para el 42,7% de los trabajadores.
Exactitud balanceada 0,7294 Promedio entre sensibilidad y especificidad: 72,9%.
AUC 0,8044 Capacidad de discriminación muy buena a partir de las probabilidades estimadas.
Nota y fuente: La clase positiva es ‘Alto’. Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.

Con el umbral óptimo de 0,503, el modelo Logit clasificó correctamente el 72,9% de las observaciones del conjunto de prueba, frente a una tasa de no información de 50,0%. La sensibilidad (65,7%) y la especificidad (80,2%) indican que el modelo identifica mejor a los trabajadores de ingreso bajo. El coeficiente Kappa (0,4589) y el AUC (0,8044) resumen la concordancia ajustada por azar y la capacidad de discriminación del modelo, respectivamente. La comparación de estos resultados con los del modelo KNN se desarrolla en la sección siguiente.

5 Comparación de modelos

Una vez evaluados de manera individual, los modelos KNN y Logit se comparan en esta sección. La comparación es válida porque ambos se entrenaron con el mismo conjunto de entrenamiento (10.414 observaciones), se evaluaron sobre el mismo conjunto de prueba (3.471 observaciones) y usan las mismas cinco variables explicativas. Además, ninguno de los dos utilizó el conjunto de prueba para tomar decisiones de ajuste: el número de vecinos k de KNN se eligió por validación cruzada dentro del entrenamiento y el umbral del Logit se definió con las probabilidades del entrenamiento.

La comparación se organiza en cuatro partes: (i) un resumen integral de las métricas, (ii) el desglose de los aciertos y errores de cada modelo, (iii) la comparación de las curvas ROC y (iv) el grado de concordancia entre las predicciones de ambos modelos.

5.1 Comparación integral de métricas

La Tabla 14 reúne en una sola vista los indicadores de desempeño de ambos modelos sobre el conjunto de prueba. La columna de diferencia se calcula como Logit menos KNN, de modo que 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.

comp_num <- data.frame(
  Métrica = c("Exactitud (Accuracy)", "Sensibilidad (Recall)", "Especificidad",
              "Valor predictivo positivo (Precisión)", "Valor predictivo negativo",
              "Exactitud balanceada", "Kappa de Cohen", "AUC"),
  KNN   = c(acc_k, sens_k, esp_k, vpp_k, vpn_k, bal_k, kap_k, auc_knn),
  Logit = c(acc_l, sens_l, esp_l, vpp_l, vpn_l, bal_l, kap_l, auc_logit)
) %>%
  mutate(
    Diferencia = Logit - KNN,
    Mejor = case_when(
      abs(Diferencia) < 0.005 ~ "A la par",
      Diferencia > 0          ~ "Logit",
      TRUE                    ~ "KNN"
    )
  )

tabla_comp <- data.frame(
  Métrica                   = comp_num$Métrica,
  KNN                       = f4(comp_num$KNN),
  Logit                     = f4(comp_num$Logit),
  `Diferencia (Logit − KNN)` = f4(comp_num$Diferencia),
  `Mejor desempeño`         = comp_num$Mejor,
  check.names = FALSE
)

kbl(
  tabla_comp,
  format = "html",
  caption = "Tabla 14. Comparación integral del desempeño de los modelos KNN y Logit (conjunto de prueba)",
  align = c("l", "c", "c", "c", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3, 5, 7),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4, 6, 8),
    background = "#FFFFFF",
    color = "#333333"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    width = "6cm",
    color = "#292929"
  ) %>%
  column_spec(
    2:4,
    width = "3cm",
    color = "#333333"
  ) %>%
  column_spec(
    5,
    bold = TRUE,
    width = "3cm",
    color = "#8A6D3B"
  ) %>%
  footnote(
    general = paste0(
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025. ",
      "La clase positiva es 'Alto'. ",
      "El Logit usa el umbral óptimo de Youden; KNN clasifica por mayoría de votos entre los k vecinos."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 14. Comparación integral del desempeño de los modelos KNN y Logit (conjunto de prueba)
Métrica KNN Logit Diferencia (Logit − KNN) Mejor desempeño
Exactitud (Accuracy) 0,7283 0,7295 0,0012 A la par
Sensibilidad (Recall) 0,6592 0,6569 -0,0023 A la par
Especificidad 0,7974 0,8020 0,0046 A la par
Valor predictivo positivo (Precisión) 0,7645 0,7680 0,0035 A la par
Valor predictivo negativo 0,7009 0,7007 -0,0002 A la par
Exactitud balanceada 0,7283 0,7294 0,0011 A la par
Kappa de Cohen 0,4566 0,4589 0,0023 A la par
AUC 0,8010 0,8044 0,0034 A la par
Nota y fuente: Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025. La clase positiva es ‘Alto’. El Logit usa el umbral óptimo de Youden; KNN clasifica por mayoría de votos entre los k vecinos.

En términos de exactitud, KNN clasifica correctamente el 72,8% de los trabajadores del conjunto de prueba y el Logit el 72,9%; la diferencia es de 0,0012 en favor del Logit. En el AUC, que resume la capacidad de ordenar correctamente a los trabajadores según su probabilidad de ingreso alto sin depender de un umbral particular, KNN obtiene 0,8010 y el Logit 0,8044; ambos valores corresponden a una capacidad de discriminación muy buena y muy buena, respectivamente. En conjunto, la brecha de desempeño entre los dos modelos es prácticamente nula, y de acuerdo con el balance entre exactitud, exactitud balanceada y AUC, el modelo con mejor desempeño global en esta muestra es Logit.

Respecto al equilibrio entre ambos tipos de acierto, KNN muestra mayor especificidad que sensibilidad, es decir, detecta mejor a los trabajadores de ingreso bajo, mientras que el Logit muestra mayor especificidad que sensibilidad, es decir, detecta mejor a los trabajadores de ingreso bajo. Por su parte, el coeficiente Kappa indica una concordancia moderada para KNN (0,4566) y moderada para el Logit (0,4589) entre lo predicho y lo observado, una vez descontado el acuerdo esperable por azar.

5.2 Aciertos y errores de clasificación

Más allá de los porcentajes, es útil comparar cuántos trabajadores clasifica bien o mal cada modelo y de qué tipo son sus errores. Un falso positivo corresponde a un trabajador de ingreso bajo clasificado como de ingreso alto, y un falso negativo a un trabajador de ingreso alto clasificado como de ingreso bajo.

tabla_err <- data.frame(
  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(vp_k, vn_k, fp_k, fn_k, vp_k + vn_k, fp_k + fn_k),
  Logit = c(vp_l, vn_l, fp_l, fn_l, vp_l + vn_l, fp_l + fn_l)
)
tabla_err$`% del conjunto de prueba (KNN)`   <- pct(tabla_err$KNN   / n_test)
tabla_err$`% del conjunto de prueba (Logit)` <- pct(tabla_err$Logit / n_test)

kbl(
  tabla_err,
  format = "html",
  caption = "Tabla 15. Aciertos y errores de clasificación de los modelos KNN y Logit",
  format.args = list(big.mark = ".", decimal.mark = ","),
  align = c("l", "c", "c", "c", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4),
    background = "#FFFFFF",
    color = "#333333"
  ) %>%
  row_spec(
    5:6,
    bold = TRUE,
    background = "#F0E8D8",
    color = "#292929",
    extra_css = "border-top: 1px solid #C5A46D;"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    width = "7cm",
    color = "#292929"
  ) %>%
  column_spec(
    2:3,
    width = "2cm",
    color = "#333333"
  ) %>%
  column_spec(
    4:5,
    width = "3.5cm",
    color = "#333333"
  ) %>%
  footnote(
    general = paste0(
      "La clase positiva es 'Alto'. ",
      "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025."
    ),
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 15. Aciertos y errores de clasificación de los modelos KNN y Logit
Indicador KNN Logit % del conjunto de prueba (KNN) % del conjunto de prueba (Logit)
Verdaderos positivos (Alto clasificado como Alto) 1.143 1.139 32,9% 32,8%
Verdaderos negativos (Bajo clasificado como Bajo) 1.385 1.393 39,9% 40,1%
Falsos positivos (Bajo clasificado como Alto) 352 344 10,1% 9,9%
Falsos negativos (Alto clasificado como Bajo) 591 595 17,0% 17,1%
Total de clasificaciones correctas 2.528 2.532 72,8% 72,9%
Total de clasificaciones incorrectas 943 939 27,2% 27,1%
Nota y fuente: La clase positiva es ‘Alto’. Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.

KNN comete 943 errores en total (352 falsos positivos y 591 falsos negativos), mientras que el Logit comete 939 (344 falsos positivos y 595 falsos negativos). Esto significa que, sobre los 3.471 trabajadores de prueba, el Logit evita 4 errores más que el otro modelo. La diferencia relevante no es solo cuántos errores se cometen, sino de qué tipo: el modelo que arroja más falsos negativos deja de identificar a trabajadores de ingreso alto, y el que arroja más falsos positivos clasifica como de ingreso alto a trabajadores que no lo son. La elección entre uno y otro depende, por tanto, de cuál de los dos errores se desee evitar con mayor prioridad.

5.3 Curvas ROC comparadas

La curva ROC permite comparar los modelos sin depender de un umbral particular: cuanto más se acerque una curva a la esquina superior izquierda, mejor discrimina el modelo entre ambas categorías de ingreso. La Figura 13 superpone las curvas de ambos modelos sobre el conjunto de prueba e incluye, como marcadores, el punto de operación de cada uno (el que resulta de su regla de clasificación final). Es interactiva: al pasar el cursor sobre cada curva se muestran la tasa de falsos positivos y la sensibilidad.

roc_k_df <- data.frame(FPR = 1 - roc_knn$specificities,
                       TPR = roc_knn$sensitivities) %>% arrange(FPR, TPR)
roc_l_df <- data.frame(FPR = 1 - roc_logit$specificities,
                       TPR = roc_logit$sensitivities) %>% arrange(FPR, TPR)

Figura_ROC_comparada <- plot_ly() %>%
  add_trace(
    x = c(0, 1), y = c(0, 1), type = "scatter", mode = "lines",
    line = list(color = "grey", dash = "dash", width = 1.5),
    name = "Línea de referencia (azar)", hoverinfo = "none"
  ) %>%
  add_trace(
    data = roc_k_df, x = ~FPR, y = ~TPR, type = "scatter", mode = "lines",
    line = list(color = dorado, width = 3), name = "KNN",
    hoverinfo = "text",
    text = ~paste0("<b>KNN</b><br>1 - Especificidad: ", round(FPR, 3),
                   "<br>Sensibilidad: ", round(TPR, 3))
  ) %>%
  add_trace(
    data = roc_l_df, x = ~FPR, y = ~TPR, type = "scatter", mode = "lines",
    line = list(color = rojo_osc, width = 3), name = "Logit",
    hoverinfo = "text",
    text = ~paste0("<b>Logit</b><br>1 - Especificidad: ", round(FPR, 3),
                   "<br>Sensibilidad: ", round(TPR, 3))
  ) %>%
  add_trace(
    x = 1 - esp_k, y = sens_k, type = "scatter", mode = "markers",
    marker = list(size = 13, color = dorado, symbol = "diamond",
                  line = list(color = rojo_osc, width = 2)),
    name = "Punto de operación KNN", hoverinfo = "text",
    text = paste0("<b>Punto de operación KNN (k = ", k_optimo, ")</b>",
                  "<br>1 - Especificidad: ", round(1 - esp_k, 3),
                  "<br>Sensibilidad: ", round(sens_k, 3))
  ) %>%
  add_trace(
    x = 1 - esp_l, y = sens_l, type = "scatter", mode = "markers",
    marker = list(size = 13, color = rojo, symbol = "circle",
                  line = list(color = "white", width = 2)),
    name = "Punto de operación Logit", hoverinfo = "text",
    text = paste0("<b>Punto de operación Logit (umbral = ", round(umbral, 3), ")</b>",
                  "<br>1 - Especificidad: ", round(1 - esp_l, 3),
                  "<br>Sensibilidad: ", round(sens_l, 3))
  ) %>%
  layout(
    title = list(
      text = "<b>Figura 13. Curvas ROC comparadas entre KNN y Logit</b>",
      font = list(family = "Playfair Display", size = 20, color = rojo), x = 0.05
    ),
    xaxis = list(
      title = list(text = "1 - Especificidad (tasa de falsos positivos)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.2, tickformat = ".1f", gridcolor = rojo_claro
    ),
    yaxis = list(
      title = list(text = "Sensibilidad (tasa de verdaderos positivos)",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.2, tickformat = ".1f", gridcolor = rojo_claro
    ),
    plot_bgcolor = "#F5F5F5", paper_bgcolor = "white",
    font = list(family = "Source Serif Pro", size = 12),
    margin = list(l = 80, r = 40, t = 90, b = 140),
    hoverlabel = list(bgcolor = "white", bordercolor = rojo_osc,
                      font = list(family = "Source Serif Pro")),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.22),
    annotations = list(
      list(x = 0.78, y = 0.20, text = paste0("<b>AUC KNN = ", round(auc_knn, 3), "</b>"),
           showarrow = FALSE,
           font = list(family = "Playfair Display", size = 15, color = "#A78342"),
           bgcolor = "rgba(255,255,255,0.85)", bordercolor = dorado,
           borderwidth = 1, borderpad = 4),
      list(x = 0.78, y = 0.08, text = paste0("<b>AUC Logit = ", round(auc_logit, 3), "</b>"),
           showarrow = FALSE,
           font = list(family = "Playfair Display", size = 15, color = rojo_osc),
           bgcolor = "rgba(255,255,255,0.85)", bordercolor = rojo_osc,
           borderwidth = 1, borderpad = 4),
      list(x = 1, y = -0.34, xref = "paper", yref = "paper",
           text = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
           showarrow = FALSE, xanchor = "right",
           font = list(family = "Source Serif Pro", size = 10, color = "#858585"))
    )
  ) %>%
  config(
    displaylogo = FALSE,
    modeBarButtonsToRemove = c("pan2d", "lasso2d", "select2d"),
    toImageButtonOptions = list(format = "png", filename = "roc_comparada",
                                width = 1000, height = 700)
  )

Figura_ROC_comparada

El AUC del Logit (0,8044) y el de KNN (0,8010) difieren en 0,0034 puntos; la prueba de DeLong no encuentra evidencia de que la diferencia entre los AUC sea estadísticamente significativa (valor p = 0,3125). Una aclaración adicional sobre la curva de KNN: como su puntuación es la proporción de votos entre k = 41 vecinos, esta toma un número limitado de valores distintos, por lo que su curva ROC resulta más escalonada que la del Logit, cuyas probabilidades son continuas. Esto es una característica propia del método y no un defecto del ajuste.

5.4 Comparación gráfica de las métricas

La Figura 14 presenta, de forma resumida, las principales métricas de ambos modelos. Permite apreciar de un vistazo en qué indicadores se separan los modelos y en cuáles son prácticamente idénticos. Es interactiva: al pasar el cursor sobre cada barra se muestran la métrica y su valor.

met_graf <- bind_rows(
  tibble(Modelo = "KNN",
         Métrica = c("Exactitud", "Exactitud balanceada", "Sensibilidad",
                     "Especificidad", "Precisión (VPP)", "AUC"),
         Valor = c(acc_k, bal_k, sens_k, esp_k, vpp_k, auc_knn)),
  tibble(Modelo = "Logit",
         Métrica = c("Exactitud", "Exactitud balanceada", "Sensibilidad",
                     "Especificidad", "Precisión (VPP)", "AUC"),
         Valor = c(acc_l, bal_l, sens_l, esp_l, vpp_l, auc_logit))
) %>%
  mutate(Métrica = factor(Métrica, levels = c("Exactitud", "Exactitud balanceada",
                                              "Sensibilidad", "Especificidad",
                                              "Precisión (VPP)", "AUC")))

Figura_metricas <- plot_ly(
  data = met_graf, x = ~Métrica, y = ~Valor, color = ~Modelo,
  colors = c("KNN" = dorado, "Logit" = rojo_osc), type = "bar",
  text = ~f4(Valor), textposition = "outside",
  hoverinfo = "text",
  hovertext = ~paste0("<b>", Modelo, "</b><br>", Métrica, ": ", f4(Valor))
) %>%
  layout(
    barmode = "group",
    title = list(
      text = "<b>Figura 14. Comparación de métricas entre KNN y Logit</b>",
      font = list(family = "Playfair Display", size = 20, color = rojo), x = 0.05
    ),
    xaxis = list(title = ""),
    yaxis = list(
      title = list(text = "Valor de la métrica",
                   font = list(family = "Playfair Display", size = 14, color = rojo_osc)),
      range = c(0, 1), dtick = 0.2, gridcolor = rojo_claro
    ),
    plot_bgcolor = "#F5F5F5", paper_bgcolor = "white",
    font = list(family = "Source Serif Pro", size = 12),
    margin = list(l = 80, r = 40, t = 90, b = 130),
    hoverlabel = list(bgcolor = "white", bordercolor = rojo_osc,
                      font = list(family = "Source Serif Pro")),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.18),
    annotations = list(
      list(x = 1, y = -0.28, xref = "paper", yref = "paper",
           text = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
           showarrow = FALSE, xanchor = "right",
           font = list(family = "Source Serif Pro", size = 10, color = "#858585"))
    )
  ) %>%
  config(
    displaylogo = FALSE,
    modeBarButtonsToRemove = c("pan2d", "lasso2d", "select2d"),
    toImageButtonOptions = list(format = "png", filename = "metricas_comparadas",
                                width = 1000, height = 650)
  )

Figura_metricas

La mayor diferencia entre ambos modelos se observa en Especificidad, con una brecha de 0,0046 a favor del A la par. Los indicadores en los que los modelos quedan “a la par” (diferencia inferior a 0,005) son: Exactitud (Accuracy); Sensibilidad (Recall); Especificidad; Valor predictivo positivo (Precisión); Valor predictivo negativo; Exactitud balanceada; Kappa de Cohen; AUC.

5.5 Concordancia entre las predicciones de ambos modelos

Finalmente, conviene verificar si los dos modelos se equivocan en los mismos trabajadores o en trabajadores distintos. Si fallaran en los mismos casos, ello sugeriría que el límite de desempeño lo impone la información de las variables y no el método; si fallaran en casos distintos, un modelo combinado podría aportar mejoras.

tabla_conc <- data.frame(
  `Resultado de la clasificación` = c("Ambos modelos aciertan",
                                      "Solo KNN acierta",
                                      "Solo Logit acierta",
                                      "Ambos modelos fallan"),
  Trabajadores = c(ambos_aciertan, solo_knn, solo_logit, ambos_fallan),
  check.names = FALSE
)
tabla_conc$`% del conjunto de prueba` <- pct(tabla_conc$Trabajadores / n_test)

kbl(
  tabla_conc,
  format = "html",
  caption = "Tabla 16. Concordancia entre los aciertos y errores de KNN y Logit",
  format.args = list(big.mark = ".", decimal.mark = ","),
  align = c("l", "c", "c"),
  escape = TRUE
) %>%
  kable_styling(
    bootstrap_options = c("hover", "condensed"),
    full_width = FALSE,
    font_size = 13,
    position = "center"
  ) %>%
  row_spec(
    0,
    background = "#171717",
    color = "#E0CCA0",
    bold = TRUE,
    extra_css = "border-bottom: 2px solid #C5A46D; padding: 12px;"
  ) %>%
  row_spec(
    c(1, 3),
    background = "#F0F0F0",
    color = "#292929",
    extra_css = "border-bottom: 1px solid #E0E0E0;"
  ) %>%
  row_spec(
    c(2, 4),
    background = "#FFFFFF",
    color = "#333333"
  ) %>%
  column_spec(
    1,
    bold = TRUE,
    width = "6cm",
    color = "#292929"
  ) %>%
  column_spec(
    2,
    width = "2.5cm",
    color = "#333333"
  ) %>%
  column_spec(
    3,
    width = "3.5cm",
    color = "#333333"
  ) %>%
  footnote(
    general = "Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.",
    general_title = "Nota y fuente:",
    footnote_as_chunk = TRUE
  )
Tabla 16. Concordancia entre los aciertos y errores de KNN y Logit
Resultado de la clasificación Trabajadores % del conjunto de prueba
Ambos modelos aciertan 2.395 69,0%
Solo KNN acierta 133 3,8%
Solo Logit acierta 137 3,9%
Ambos modelos fallan 806 23,2%
Nota y fuente: Fuente: elaboración propia con base en la GEIH del DANE, junio de 2025.

Los dos modelos asignan la misma categoría al 92,2% de los trabajadores del conjunto de prueba. Ambos aciertan simultáneamente en el 69,0% de los casos y fallan simultáneamente en el 23,2%, mientras que en el 7,8% restante uno acierta y el otro no. La prueba de McNemar sobre estos casos discordantes no encuentra evidencia de que uno de los modelos acierte significativamente más que el otro (valor p = 0,8551). Esta alta coincidencia indica que la mayor parte de los errores no depende del método elegido, sino de la información contenida en las cinco variables explicativas, lo cual será relevante en la discusión de las conclusiones.

5.6 Recomendación

KNN y Logit presentan un desempeño predictivo muy similar: el Logit acertó el 72,9 % de los casos y KNN el 72,8 %, con AUC de 0,8044 y 0,8010. Esas diferencias no son significativas (DeLong p = 0,3125; McNemar p = 0,8551), y los dos coinciden en el 92,2 % de sus predicciones.

Como no se encontraron diferencias estadísticamente significativas, se recomienda el Logit por tres razones. Primero, explica los resultados: sus odds ratios muestran qué características (educación, tipo de empleado, sexo, edad y horas) se asocian con ingreso alto y si esa asociación es significativa, algo que KNN no ofrece. Segundo, es más simple: se resume en pocos coeficientes y se aplica a casos nuevos con una ecuación fija, mientras que KNN necesita guardar toda la base de entrenamiento. Tercero, su umbral de decisión (0,503) se puede ajustar según el costo de cada tipo de error (Figura 12).

La recomendación se basa en la interpretabilidad y la simplicidad del Logit, no en que prediga mejor, y sus resultados son asociaciones, no causalidad.

6 Conclusiones

Planteamiento y alcance del estudio

Este trabajo buscó responder una pregunta concreta: ¿qué tan bien permiten las características personales, educativas y laborales de un trabajador clasificar su ingreso laboral como relativamente bajo o alto? Para ello se utilizaron los microdatos de la Gran Encuesta Integrada de Hogares (GEIH) del DANE correspondientes a junio de 2025, una base analítica de 13.885 trabajadores (13.885 con información completa en las cinco variables explicativas), y se definió una variable binaria de ingreso a partir de la mediana de la muestra ($1.580.000 COP). Por construcción, esta definición es relativa a la muestra: no corresponde a una línea de pobreza ni a una medida oficial de suficiencia salarial, y deja las dos categorías prácticamente equilibradas (50,0% de ingresos altos), lo que permitió usar la exactitud como una métrica razonable y facilitó la comparación entre modelos.

El objetivo fue evaluar y comparar dos enfoques de aprendizaje supervisado de naturaleza distinta: K vecinos más cercanos (KNN), un método no paramétrico basado en la similitud entre observaciones, y la regresión logística (Logit), un método paramétrico que estima la probabilidad de pertenecer al grupo de ingreso alto y permite interpretar el aporte de cada variable. Ambos se evaluaron sobre un mismo conjunto de prueba independiente y se compararon con las mismas métricas.

Lo que mostró el análisis descriptivo

Antes de modelar, el análisis descriptivo ya anticipaba qué variables tenían mayor capacidad para distinguir entre ingresos altos y bajos. El patrón más marcado se observó en el nivel educativo: la proporción de trabajadores con ingreso alto crece de 24,7% en primaria a 35,0% en secundaria, 51,0% en formación técnica o tecnológica, 71,7% en universitaria y 98,0% en posgrado. El tipo de empleado mostró un contraste igualmente fuerte: 96,0% de los empleados del gobierno tiene ingreso alto, frente a 44,2% de los empleados particulares. En contraste, el sexo separó poco a los grupos (51,2% de los hombres y 48,6% de las mujeres con ingreso alto), y las variables cuantitativas mostraron diferencias modestas: la edad promedio fue de 40,02 años en el grupo alto y de 36,69 en el bajo, y las horas semanales promedio fueron de 46,93 y 45,58, respectivamente.

La matriz de correlaciones reforzó esta lectura: la correlación lineal entre la edad y el ingreso laboral es débil (0,1733) y la de las horas trabajadas con el ingreso es prácticamente nula (0,0058). Esto indica que, en esta base, el ingreso no se explica por la intensidad de la jornada y que el efecto de la edad, aunque positivo, es leve. Además, la correlación prácticamente nula entre edad y horas trabajadas sugiere que no hay problemas de multicolinealidad entre las variables cuantitativas, lo que favorece la estimación e interpretación del Logit.

Lo que mostraron los modelos

Los resultados de los modelos son coherentes con lo observado en la descripción. En el Logit, 8 de los 8 coeficientes resultaron significativos al 5 %. Las asociaciones más fuertes se dieron con el nivel educativo y el tipo de empleado: frente a la educación primaria, las posibilidades de ingreso alto se multiplican por 1,99 en secundaria, 4,28 en formación técnica o tecnológica, 11,15 en universitaria y 147,01 en posgrado (efecto estadísticamente significativo para este último), y los empleados del gobierno tienen posibilidades 14,43 veces las de los particulares, manteniendo constantes las demás variables. El efecto de ser mujer frente a ser hombre (odds ratio de 0,61) es estadísticamente significativo; el de la edad (1,02 por año adicional) es estadísticamente significativo; y el de las horas trabajadas (1,04 por hora semanal adicional) es estadísticamente significativo. Estas lecturas son asociaciones estadísticas condicionadas a las demás variables y no relaciones causales.

En KNN, la validación cruzada de 5 particiones dentro del entrenamiento seleccionó k = 41, con una exactitud promedio de 72,6%. Al evaluarlo sobre el conjunto de prueba, la exactitud fue de 72,8%, prácticamente igual a la estimada en validación cruzada, lo que sugiere que el procedimiento de selección de k fue estable y que el modelo no sobreajustó. Que el valor óptimo de k sea relativamente alto indica que el modelo se beneficia de suavizar sus decisiones promediando entre varios vecinos, algo esperable cuando las categorías de ingreso se solapan considerablemente.

Lo que mostró la comparación de modelos

Los dos modelos alcanzaron un desempeño prácticamente idéntico. El Logit clasificó correctamente al 72,9% de los trabajadores del conjunto de prueba (AUC de 0,8044, Kappa de 0,4589) y KNN al 72,8% (AUC de 0,8010, Kappa de 0,4566), frente a una tasa de no información de 50,0%. Es decir, ambos superan el criterio trivial de predecir siempre la clase más frecuente, y su capacidad de discriminación se califica como muy buena en KNN y muy buena en el Logit. De acuerdo con el balance entre exactitud, exactitud balanceada y AUC, el modelo con mejor desempeño global fue el Logit; sin embargo, la prueba de DeLong no encuentra evidencia de que la diferencia entre los AUC sea estadísticamente significativa (valor p = 0,3125), por lo que la ventaja debe interpretarse con cautela y no como una superioridad contundente.

Los modelos se diferencian más en el tipo de error que en la cantidad de errores. KNN presentó 352 falsos positivos y 591 falsos negativos, mientras que el Logit presentó 344 y 595, respectivamente. Así, si el interés es no dejar escapar a los trabajadores de ingreso alto, importa la sensibilidad (65,9% en KNN y 65,7% en el Logit); si el interés es no etiquetar como de ingreso alto a quien no lo es, importan la especificidad (79,7% y 80,2%) y la precisión (76,5% y 76,8%). Además, 92,2% de las predicciones de ambos modelos coinciden, lo que muestra que los dos están extrayendo prácticamente la misma señal de las variables disponibles.

¿Se cumplió el objetivo del estudio?

Sí, el estudio cumplió su objetivo, con un matiz importante. Se construyeron, evaluaron y compararon dos modelos de clasificación bajo un diseño metodológico riguroso, y se demostró que las características personales, educativas y laborales contienen información útil para clasificar el ingreso: ambos modelos superan la clasificación trivial (tasa de no información de 50,0%) y superan a un modelo aleatorio (AUC de 0,5). Sin embargo, una capacidad de discriminación muy buena no equivale a una predicción precisa a nivel individual. Cerca de 27% de los trabajadores del conjunto de prueba son clasificados incorrectamente aun por el mejor modelo, lo que significa que, con estas cinco variables, el ingreso de una persona concreta no puede predecirse con la confiabilidad que exigiría una decisión individual.

La razón de este límite se desprende de las secciones anteriores. Dos de las cinco variables (nivel educativo y tipo de empleado) concentran casi toda la capacidad de discriminación; el sexo aporta poco y la edad y las horas trabajadas aportan señales débiles, como ya lo habían anticipado las correlaciones. Además, la alta coincidencia entre las predicciones de KNN y Logit indica que el techo de desempeño lo fija la información disponible y no el algoritmo: cambiar de método, entre estos dos, no cambia sustancialmente los resultados. Los determinantes del ingreso que no están en la base, como la experiencia laboral específica, la ocupación u oficio, el sector económico, el tamaño de la empresa, la formalidad del empleo, la región o la ciudad de residencia, probablemente explican buena parte de la variabilidad restante.

Implicaciones prácticas

Desde el punto de vista interpretativo, el Logit resulta el modelo más útil para describir el fenómeno, pues cuantifica la asociación de cada característica con la probabilidad de ingreso alto, mientras que KNN funciona como una referencia no paramétrica cuya principal virtud es que, al no imponer una forma funcional, permite contrastar si el Logit pierde información relevante por ser lineal en el índice; cuando ambos rinden de forma parecida, esa pérdida parece pequeña. Desde el punto de vista predictivo, la elección dependería del costo relativo de los errores: un modelo con mayor sensibilidad convendría para identificar al grupo de ingreso alto con la menor omisión posible, y uno con mayor especificidad y precisión, para minimizar la clasificación errónea de trabajadores de ingreso bajo como de ingreso alto. En el Logit, además, este equilibrio puede ajustarse de forma explícita moviendo el umbral de decisión, como se ilustró en la Figura 12, algo que en KNN no es tan directo.

Un resultado con relevancia para el análisis del mercado laboral es el peso de la educación y del tipo de empleado frente al sexo y a las horas trabajadas. La jornada semanal no discrimina entre ingresos altos y bajos en esta muestra, lo que sugiere que trabajar más horas no se traduce, por sí solo, en pertenecer al grupo de ingreso alto; en cambio, el nivel educativo muestra un gradiente creciente y consistente. Esto es coherente con la idea de que la formación académica y la inserción en cierto tipo de empleo están fuertemente asociadas con la remuneración, aunque, por tratarse de un estudio observacional, no permite afirmar que la educación o el tipo de empleado causen el ingreso.

Limitaciones del estudio

Es necesario reconocer varias limitaciones. Primera, la variable dependiente se construyó dicotomizando un ingreso continuo en la mediana de la muestra; esto simplifica el problema, pero descarta información (un trabajador que gana un peso más que la mediana se trata igual que uno que gana el triple) y vuelve especialmente difícil clasificar a quienes están cerca del punto de corte, que probablemente explican una parte importante de los errores. Segunda, la mediana se calculó con la base completa antes de separar entrenamiento y prueba; aunque el ingreso no se usa como predictor, esta decisión incorpora información de la distribución global en la definición de la variable dependiente, lo que constituye una limitación metodológica menor que debe tenerse presente.

Tercera, los datos corresponden a un único mes (junio de 2025) y a una selección de trabajadores de la GEIH, por lo que los resultados no deben generalizarse automáticamente a toda la población colombiana ni a otros periodos. Cuarta, el Logit supone una relación lineal en el logit para la edad y las horas trabajadas y no incluye interacciones, por lo que podría estar omitiendo patrones como los rendimientos decrecientes de la edad o efectos distintos de la educación según el tipo de empleado. Quinta, KNN depende de la forma en que se codifican y estandarizan las variables categóricas, y su evaluación se basó en una sola partición de prueba; los resultados podrían variar levemente con otra semilla o con validación cruzada repetida. Finalmente, ya se señaló que ninguna de las asociaciones encontradas debe leerse como causal.

Recomendaciones y trabajo futuro

A partir de los hallazgos, se proponen las siguientes líneas de mejora. En primer lugar, enriquecer la base con variables que la literatura laboral identifica como determinantes del ingreso (experiencia, ocupación, sector, tamaño de empresa, formalidad, región), ya que son la vía más probable para superar el techo de desempeño observado. En segundo lugar, incorporar términos no lineales e interacciones en el Logit (por ejemplo, la edad al cuadrado o la interacción entre educación y tipo de empleado) y evaluar versiones regularizadas (LASSO o ridge). En tercer lugar, contrastar estos modelos con métodos de mayor flexibilidad, como bosques aleatorios o gradient boosting, y con modelos de regresión sobre el ingreso continuo o en más de dos categorías (por ejemplo, terciles o quintiles), que aprovecharían mejor la información. En cuarto lugar, reforzar la evaluación con validación cruzada repetida y con intervalos de confianza para las métricas, y definir el umbral de decisión según el costo relativo de los errores del problema concreto. Por último, convendría analizar los casos mal clasificados por ambos modelos para identificar qué perfiles de trabajadores son sistemáticamente difíciles de clasificar.

Reflexión final

En síntesis, este estudio muestra que es posible clasificar el ingreso laboral de los trabajadores colombianos mejor que el azar y mejor que la regla trivial usando solo cinco características sociodemográficas y laborales, y que tanto un método paramétrico como uno no paramétrico llegan a un desempeño prácticamente igual. El nivel educativo y el tipo de empleado son los factores que más separan a los grupos de ingreso, mientras que el sexo, la edad y las horas trabajadas aportan poco. El Logit se perfila como la mejor opción cuando el propósito es explicar y comunicar los resultados, y KNN, como un contraste útil que confirma la solidez de los hallazgos. Pero con la información disponible, ninguno de los dos alcanza un nivel de precisión suficiente para decidir sobre individuos concretos; para ello se requeriría información adicional y modelos más flexibles. Los resultados aportan, por tanto, una lectura descriptiva y predictiva valiosa del mercado laboral en la muestra, sin llegar a ser un instrumento operativo por sí solos.

7 Bibliografía

[1] Departamento Administrativo Nacional de Estadística [DANE]. (2025). Gran Encuesta Integrada de Hogares (GEIH), junio de 2025 [Microdatos]. Catálogo de microdatos del DANE. https://microdatos.dane.gov.co/

[2] Becker, G. S. (1964). Human capital: A theoretical and empirical analysis, with special reference to education. National Bureau of Economic Research.

[3] Cohen, J. (1960). A coefficient of agreement for nominal scales. Educational and Psychological Measurement, 20(1), 37-46.

[4] Cover, T., & Hart, P. (1967). Nearest neighbor pattern classification. IEEE Transactions on Information Theory, 13(1), 21-27.

[5] 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.

[6] Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition Letters, 27(8), 861-874.

[7] Hastie, T., Tibshirani, R., & Friedman, J. (2009). The elements of statistical learning: Data mining, inference, and prediction (2.ª ed.). Springer.

[8] Hosmer, D. W., Lemeshow, S., & Sturdivant, R. X. (2013). Applied logistic regression (3.ª ed.). John Wiley & Sons.

[9] James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). An introduction to statistical learning: With applications in R (2.ª ed.). Springer.

[10] Kuhn, M. (2008). Building predictive models in R using the caret package. Journal of Statistical Software, 28(5), 1-26.

[11] Landis, J. R., & Koch, G. G. (1977). The measurement of observer agreement for categorical data. Biometrics, 33(1), 159-174.

[12] McNemar, Q. (1947). Note on the sampling error of the difference between correlated proportions or percentages. Psychometrika, 12(2), 153-157.

[13] R Core Team. (2025). R: A language and environment for statistical computing. R Foundation for Statistical Computing. https://www.R-project.org/

[14] 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.

[15] Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC.

[16] Venables, W. N., & Ripley, B. D. (2002). Modern applied statistics with S (4.ª ed.). Springer.

[17] Wickham, H., Averick, M., Bryan, J., Chang, W., McGowan, L. D., François, R., Grolemund, G., Hayes, A., Henry, L., Hester, J., Kuhn, M., Pedersen, T. L., Miller, E., Bache, S. M., Müller, K., Ooms, J., Robinson, D., Seidel, D. P., Spinu, V., … Yutani, H. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686.

[18] Wooldridge, J. M. (2012). Introductory econometrics: A modern approach (5.ª ed.). South-Western Cengage Learning.

[19] Xie, Y., Allaire, J. J., & Grolemund, G. (2018). R Markdown: The definitive guide. Chapman and Hall/CRC.

[20] Youden, W. J. (1950). Index for rating diagnostic tests. Cancer, 3(1), 32-35.