1 Resumen ejecutivo

Este informe presenta un análisis estadístico multivariado de 8.322 anuncios de vivienda publicados en OLX para una gran ciudad colombiana (coordenadas consistentes con Santiago de Cali), con el fin de apoyar las decisiones de compra, venta y valoración de una empresa inmobiliaria. Sobre una base depurada de 8.319 propiedades, se aplicaron tres técnicas de análisis multivariado (Componentes Principales, Conglomerados y Correspondencias) que, en conjunto, permiten responder tres preguntas de negocio: ¿qué características explican el valor de una propiedad? (PCA), ¿existen segmentos naturales de mercado y cómo se describen? (Conglomerados) y ¿cómo se asocian zona, tipo de inmueble, estrato y barrio entre sí? (Correspondencias).

Los hallazgos principales son: (1) dos ejes resumen el 82.3% de la variación del mercado: un eje de “valor global” y un eje que contrapone tamaño (habitaciones) contra categoría/ubicación (estrato y precio por m²); (2) el mercado se organiza en seis segmentos claramente diferenciados, desde vivienda económica de entrada hasta un segmento de lujo concentrado en Zona Oeste; y (3) existe una asociación estadísticamente significativa y muy fuerte entre zona, tipo de inmueble, estrato y barrio (todas las pruebas chi-cuadrado con p < 0.001), confirmando que la ubicación es, en la práctica, un proxy directo de la categoría socioeconómica de la oferta. Las recomendaciones estratégicas derivadas de estos hallazgos se detallan en la sección final del documento.

2 Objetivos

2.1 Objetivo general

Realizar un análisis estadístico multivariado integral de la base de datos de oferta de vivienda urbana, que permita identificar patrones, relaciones y segmentaciones relevantes para la toma de decisiones estratégicas de una empresa inmobiliaria.

2.2 Objetivos específicos

  1. Evaluar la calidad de los datos y aplicar un proceso de limpieza estadísticamente justificado.
  2. Aplicar un Análisis de Componentes Principales para reducir la dimensionalidad del conjunto de variables cuantitativas e identificar los ejes que explican la variación de precios y características de la oferta.
  3. Aplicar un Análisis de Conglomerados para segmentar las propiedades en grupos homogéneos e interpretables desde una óptica comercial.
  4. Aplicar un Análisis de Correspondencias (simple y múltiple) para examinar la relación entre las variables categóricas zona, tipo de inmueble, estrato y barrio.
  5. Sintetizar los hallazgos de las tres técnicas en conclusiones y recomendaciones estratégicas accionables para la dirección de la empresa.

3 Introducción

Una empresa inmobiliaria líder en una gran ciudad busca comprender en profundidad el mercado de vivienda urbana para soportar decisiones estratégicas de compra, venta y valoración de propiedades. Para ello se dispone de una base de datos con 8.322 registros de propiedades residenciales publicadas en OLX, obtenida mediante web scraping y distribuida en el paquete de R paqueteMODELOS.

Este documento desarrolla el proyecto completo en las siguientes etapas:

  1. Obtención de los datos (instalación del paquete fuente y carga del dataset).
  2. Exploración inicial: estructura, calidad y consistencia de los datos.
  3. Limpieza y preparación: tratamiento de valores faltantes, duplicados y valores implausibles, con la justificación estadística de cada decisión.
  4. Análisis de Componentes Principales (PCA): reducción de dimensionalidad e identificación de los ejes que explican la variación del mercado.
  5. Análisis de Conglomerados: segmentación de las propiedades en grupos homogéneos, interpretados desde una óptica de negocio.
  6. Análisis de Correspondencias: relación entre zona, tipo de inmueble, estrato y barrio.
  7. Síntesis, conclusiones y recomendaciones estratégicas para la dirección de la empresa.

Cada técnica se presenta identificando explícitamente sus elementos constitutivos (valores propios, inercia, contribuciones, calidad de representación, pruebas de hipótesis, etc.), de forma que el documento sirva tanto como reporte de resultados como referencia metodológica del análisis realizado.

4 Obtención de los datos

Los datos provienen del paquete paqueteMODELOS (repositorio de GitHub dgonxalex80/paqueteMODELOS), que contiene el objeto vivienda. La instalación y carga se realizan así:

# Instala el paquete fuente solo si aún no está disponible en este equipo
if (!requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes")
  remotes::install_github("dgonxalex80/paqueteMODELOS", force = TRUE)
}

library(paqueteMODELOS)
data(vivienda)
vivienda <- as.data.frame(vivienda)

4.1 Estructura general

glimpse(vivienda)
## Rows: 8,322
## Columns: 13
## $ id           <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona         <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso         <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato      <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom      <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst    <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios       <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo         <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio       <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud     <dbl> -76.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud      <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…

El conjunto original contiene 8322 registros y 13 variables:

Variable Descripción
id Identificador único de la propiedad
zona Zona geográfica de la ciudad (Norte, Sur, Oriente, Oeste, Centro)
piso Piso del inmueble (aplica principalmente a apartamentos)
estrato Estrato socioeconómico (3 a 6)
preciom Precio de venta, en millones de pesos (COP)
areaconst Área construida, en m²
parqueaderos Número de parqueaderos
banios Número de baños
habitaciones Número de habitaciones
tipo Tipo de inmueble (Casa / Apartamento)
barrio Barrio donde se ubica la propiedad
longitud, latitud Coordenadas geográficas

5 Diagnóstico de calidad de datos

Antes de cualquier análisis multivariado es indispensable verificar la calidad de los datos: valores faltantes, duplicados, consistencia lógica y valores extremos. Un PCA, un conglomerado o una correspondencia mal alimentados con datos sucios producen conclusiones erróneas; por eso esta etapa se documenta con el mismo rigor que el análisis final.

5.1 Valores faltantes

na_tab <- vivienda %>%
  summarise(across(everything(), ~ sum(is.na(.)))) %>%
  pivot_longer(everything(), names_to = "variable", values_to = "n_na") %>%
  mutate(pct_na = round(100 * n_na / nrow(vivienda), 2)) %>%
  arrange(desc(n_na))

kable(na_tab, caption = tab_cap("Valores faltantes por variable (datos originales)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 1. Valores faltantes por variable (datos originales)
variable n_na pct_na
piso 2638 31.70
parqueaderos 1605 19.29
id 3 0.04
zona 3 0.04
estrato 3 0.04
areaconst 3 0.04
banios 3 0.04
habitaciones 3 0.04
tipo 3 0.04
barrio 3 0.04
longitud 3 0.04
latitud 3 0.04
preciom 2 0.02
ggplot(na_tab %>% filter(n_na > 0), aes(x = reorder(variable, pct_na), y = pct_na)) +
  geom_col(fill = "#2c7fb8") +
  coord_flip() +
  labs(x = NULL, y = "% de registros con NA") +
  theme(panel.grid.minor = element_blank())
Figura 1. Porcentaje de valores faltantes por variable

Figura 1. Porcentaje de valores faltantes por variable

Hallazgos clave:

  • Tres registros (3, los últimos del archivo) están casi enteramente vacíos: son residuos del proceso de scraping (probablemente filas de pie de archivo) y deben eliminarse por completo.
  • parqueaderos tiene un 19.3% de datos faltantes: es razonable asumir que corresponde a anuncios donde el vendedor simplemente no reportó este dato, no a una ausencia real de parqueadero.
  • piso tiene un 31.7% de datos faltantes, pero esto no es aleatorio: al desagregar por tipo de inmueble, el 39% de las casas no reportan piso frente al 27% de los apartamentos (coherente con el hecho de que el concepto “piso” aplica naturalmente a apartamentos y no siempre a casas). Esto se confirma en la siguiente tabla:
vivienda %>%
  filter(!is.na(tipo)) %>%
  group_by(tipo) %>%
  summarise(n = n(), piso_NA = sum(is.na(piso)),
            pct_NA = round(100 * piso_NA / n, 1)) %>%
  kable(caption = tab_cap("Datos faltantes de 'piso' según tipo de inmueble")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 2. Datos faltantes de ‘piso’ según tipo de inmueble
tipo n piso_NA pct_NA
Apartamento 5100 1381 27.1
Casa 3219 1254 39.0

5.2 Duplicados

cat("Filas duplicadas exactas:", sum(duplicated(vivienda)), "\n")
## Filas duplicadas exactas: 1

Solo se identifica 1 fila duplicada exacta, correspondiente a las dos filas casi vacías detectadas en el punto anterior; no hay duplicidad real de anuncios en los datos válidos.

5.3 Consistencia lógica: valores en cero implausibles

Una vivienda con área construida considerable no puede tener, de forma realista, cero habitaciones o cero baños. Se revisó cuántos registros presentan esta inconsistencia:

vivienda %>%
  filter(!is.na(tipo)) %>%
  group_by(tipo) %>%
  summarise(n = n(),
            habitaciones_0 = sum(habitaciones == 0, na.rm = TRUE),
            banios_0 = sum(banios == 0, na.rm = TRUE)) %>%
  kable(caption = tab_cap("Registros con 0 habitaciones / 0 baños, por tipo")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 3. Registros con 0 habitaciones / 0 baños, por tipo
tipo n habitaciones_0 banios_0
Apartamento 5100 21 14
Casa 3219 45 31

Ejemplo ilustrativo del problema (viviendas de tamaño y precio normales que reportan cero habitaciones):

vivienda %>%
  filter(habitaciones == 0) %>%
  select(id, zona, tipo, estrato, preciom, areaconst, habitaciones, banios) %>%
  head(5) %>%
  kable(caption = tab_cap("Ejemplo de registros con 0 habitaciones a pesar de tamaño y precio normales")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 4. Ejemplo de registros con 0 habitaciones a pesar de tamaño y precio normales
id zona tipo estrato preciom areaconst habitaciones banios
243 Zona Norte Casa 3 190 435 0 0
2013 Zona Norte Casa 3 270 330 0 3
2014 Zona Norte Casa 3 270 330 0 3
2741 Zona Norte Casa 4 485 320 0 4
3273 Zona Norte Casa 3 400 324 0 0

Estos casos (66 en habitaciones, 45 en baños; ~1.3% y ~0.9% de los datos respectivamente) se interpretan como datos no reportados capturados como cero en lugar de ausencia real de la característica, y se tratarán como valores faltantes.

5.4 Valores atípicos (outliers)

num_vars <- c("preciom","areaconst","parqueaderos","banios","habitaciones")
outlier_tab <- lapply(num_vars, function(v) {
  x <- vivienda[[v]]
  q1 <- quantile(x, .25, na.rm = TRUE); q3 <- quantile(x, .75, na.rm = TRUE)
  iqr <- q3 - q1
  lo <- q1 - 1.5*iqr; hi <- q3 + 1.5*iqr
  n_out <- sum(x < lo | x > hi, na.rm = TRUE)
  data.frame(variable = v, min = min(x, na.rm=TRUE), max = max(x, na.rm=TRUE),
             lim_inf = round(lo,1), lim_sup = round(hi,1),
             n_outliers = n_out, pct_outliers = round(100*n_out/nrow(vivienda),2))
}) %>% bind_rows()

kable(outlier_tab, caption = tab_cap("Valores atípicos según criterio IQR (1.5×RIC)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 5. Valores atípicos según criterio IQR (1.5×RIC)
variable min max lim_inf lim_sup n_outliers pct_outliers
25%…1 preciom 58 1999 -260.0 1020.0 552 6.63
25%…2 areaconst 30 1745 -143.5 452.5 382 4.59
25%…3 parqueaderos 1 10 -0.5 3.5 567 6.81
25%…4 banios 0 10 -1.0 7.0 72 0.87
25%…5 habitaciones 0 10 1.5 5.5 888 10.67
vivienda %>%
  select(all_of(num_vars)) %>%
  pivot_longer(everything(), names_to = "variable", values_to = "valor") %>%
  ggplot(aes(x = variable, y = valor)) +
  geom_boxplot(fill = "#41b6c4", outlier.alpha = 0.35, outlier.size = 1) +
  facet_wrap(~variable, scales = "free", ncol = 3) +
  labs(x = NULL, y = NULL) +
  theme(axis.text.x = element_blank())
Figura 2. Distribución y valores atípicos de variables numéricas

Figura 2. Distribución y valores atípicos de variables numéricas

El criterio IQR marca entre el 0.9% y el 10.7% de los registros como atípicos según la variable, pero esto no implica que sean errores: el mercado inmobiliario tiene, por naturaleza, una cola derecha larga (propiedades de alto valor). Al revisar los casos extremos de preciom y areaconst se observa que son consistentes internamente (a mayor área y estrato, mayor precio), por lo que se conservan en el análisis. Para el PCA se aplicará una transformación logarítmica que reduzca su influencia desproporcionada.

vivienda %>%
  arrange(desc(preciom)) %>%
  select(id, zona, tipo, estrato, preciom, areaconst, habitaciones, banios) %>%
  head(5) %>%
  kable(caption = tab_cap("Top 5 propiedades de mayor precio (verificación de coherencia)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 6. Top 5 propiedades de mayor precio (verificación de coherencia)
id zona tipo estrato preciom areaconst habitaciones banios
6100 Zona Oeste Casa 5 1999 800 5 7
7510 Zona Oeste Casa 6 1950 400 3 5
4609 Zona Oeste Apartamento 6 1950 450 4 5
7872 Zona Oeste Casa 6 1950 400 3 5
4564 Zona Norte Casa 5 1940 734 10 8

5.5 Coherencia entre variables numéricas

cor_check <- cor(vivienda[, c("preciom","areaconst","estrato","habitaciones","banios","parqueaderos")],
                  use = "pairwise.complete.obs")
kable(round(cor_check, 2), caption = tab_cap("Matriz de correlación (datos originales, verificación de sentido)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 7. Matriz de correlación (datos originales, verificación de sentido)
preciom areaconst estrato habitaciones banios parqueaderos
preciom 1.00 0.69 0.61 0.26 0.67 0.69
areaconst 0.69 1.00 0.27 0.52 0.65 0.58
estrato 0.61 0.27 1.00 -0.07 0.42 0.42
habitaciones 0.26 0.52 -0.07 1.00 0.59 0.28
banios 0.67 0.65 0.42 0.59 1.00 0.57
parqueaderos 0.69 0.58 0.42 0.28 0.57 1.00

Todas las correlaciones tienen el signo esperado (precio, área, estrato, baños y parqueaderos se mueven juntos), lo que da confianza en la validez general de los datos capturados.

6 Limpieza y preparación de los datos

Con base en el diagnóstico anterior se aplican, en orden, las siguientes transformaciones:

n0 <- nrow(vivienda)

# 1) Eliminar filas "basura" (residuo del scraping, casi todo NA)
fila_na <- rowSums(is.na(vivienda))
vivienda_c <- vivienda[fila_na < 10, ]
n1 <- nrow(vivienda_c)

# 2) Eliminar registros con NA en variables estructurales clave
clave <- c("id","zona","tipo","estrato","preciom","areaconst",
           "habitaciones","banios","longitud","latitud")
vivienda_c <- vivienda_c[rowSums(is.na(vivienda_c[, clave])) == 0, ]
n2 <- nrow(vivienda_c)

# 3) Eliminar duplicados exactos remanentes
vivienda_c <- vivienda_c[!duplicated(vivienda_c), ]
n3 <- nrow(vivienda_c)

# 4) Recodificar ceros implausibles como NA
vivienda_c$habitaciones[vivienda_c$habitaciones == 0] <- NA
vivienda_c$banios[vivienda_c$banios == 0] <- NA

# 5) Imputación por mediana agrupada (tipo x estrato) para variables de conteo
impute_group_median <- function(df, var) {
  df %>%
    group_by(tipo, estrato) %>%
    mutate("{var}" := ifelse(is.na(.data[[var]]),
                              median(.data[[var]], na.rm = TRUE),
                              .data[[var]])) %>%
    ungroup()
}
vivienda_c <- impute_group_median(vivienda_c, "parqueaderos")
vivienda_c <- impute_group_median(vivienda_c, "habitaciones")
vivienda_c <- impute_group_median(vivienda_c, "banios")
vivienda_c$parqueaderos <- round(vivienda_c$parqueaderos)
vivienda_c$habitaciones <- round(vivienda_c$habitaciones)
vivienda_c$banios       <- round(vivienda_c$banios)

# 6) piso: se conserva con una categoría explícita "No reportado"
vivienda_c$piso_cat <- ifelse(is.na(vivienda_c$piso), "No reportado", vivienda_c$piso)

# 7) Tipos de dato definitivos
vivienda_c$zona      <- factor(vivienda_c$zona)
vivienda_c$tipo      <- factor(vivienda_c$tipo)
vivienda_c$barrio    <- factor(vivienda_c$barrio)
vivienda_c$estrato_f <- factor(vivienda_c$estrato, ordered = TRUE)

# 8) Variable derivada: precio por m2 (millones COP / m2); metrica clave de valoracion
vivienda_c$precio_m2 <- round(vivienda_c$preciom / vivienda_c$areaconst, 3)

# 9) Normalizacion de texto en 'barrio': el campo tiene inconsistencias de
#    mayusculas/tildes que generan categorias duplicadas (ej. "Ciudad Jardín",
#    "ciudad jardin", "ciudad jardín" refieren al mismo barrio). Se usa
#    stringi (independiente del locale del sistema) para estandarizar.
normalizar_texto <- function(x) {
  x <- stringi::stri_trans_general(as.character(x), "Latin-ASCII")
  x <- tolower(trimws(x))
  x <- gsub("[^a-z0-9 ]", " ", x)
  x <- gsub("\\s+", " ", x)
  trimws(x)
}
n_barrios_antes <- nlevels(vivienda_c$barrio)
vivienda_c$barrio <- factor(normalizar_texto(vivienda_c$barrio))
n_barrios_despues <- nlevels(vivienda_c$barrio)

vivienda <- vivienda_c

La normalización de texto reduce el número de barrios distintos de 436 a 389, al consolidar variantes de un mismo barrio que solo diferían en mayúsculas o tildes (por ejemplo, “Ciudad Jardín”, “ciudad jardin” y “ciudad jardín” quedan unificados en “ciudad jardin”). Esta limpieza es indispensable antes de usar barrio en el Análisis de Correspondencias, donde cada categoría espuria distorsiona las distancias chi-cuadrado entre perfiles.

Resumen del proceso:

resumen <- data.frame(
  Paso = c("Filas originales",
           "Tras eliminar filas basura (residuo scraping)",
           "Tras eliminar NA en variables clave",
           "Tras eliminar duplicados",
           "Filas finales"),
  Filas = c(n0, n1, n2, n3, nrow(vivienda))
)
kable(resumen, caption = tab_cap("Resumen del proceso de limpieza de datos, paso a paso")) %>% kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 8. Resumen del proceso de limpieza de datos, paso a paso
Paso Filas
Filas originales 8322
Tras eliminar filas basura (residuo scraping) 8319
Tras eliminar NA en variables clave 8319
Tras eliminar duplicados 8319
Filas finales 8319
  • 3 registros eliminados por ser residuos vacíos del scraping.
  • Imputación por mediana condicional (tipo × estrato) para parqueaderos, habitaciones y banios: se prefiere sobre la mediana global o la eliminación de casos porque preserva las diferencias estructurales entre casas/apartamentos y entre estratos (una mediana única subestimaría los conteos en estratos altos y los sobreestimaría en estratos bajos).
  • piso se conserva con la categoría explícita “No reportado” en lugar de imputarse, dado que no es una variable numérica continua y su ausencia es informativa (más frecuente en casas).
  • Se crea la variable derivada precio_m2 (precio por metro cuadrado), una métrica de valoración estándar en tasación inmobiliaria que se usará como insumo adicional en los análisis siguientes.
  • Se normaliza el texto de barrio (minúsculas, sin tildes, espacios estandarizados) para eliminar categorías duplicadas por inconsistencias de digitación, reduciendo de 436 a 389 barrios distintos, un requisito de calidad para el Análisis de Correspondencias que se presenta más adelante en este informe.
sapply(vivienda, function(x) sum(is.na(x))) %>%
  {data.frame(variable = names(.), NA_restantes = as.integer(.))} %>%
  filter(NA_restantes > 0 | variable == "piso") %>%
  kable(caption = tab_cap("Valores faltantes tras la limpieza (piso se deja intacto por diseño)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 9. Valores faltantes tras la limpieza (piso se deja intacto por diseño)
variable NA_restantes
piso 2635

El conjunto de datos limpio queda con 8319 registros y 16 variables (13 originales + piso_cat, estrato_f y precio_m2 derivadas), sin valores faltantes en las variables usadas para los análisis cuantitativos.

saveRDS(vivienda, "data/vivienda_clean.rds")

7 Exploración descriptiva

7.1 Resumen estadístico

vivienda %>%
  select(preciom, areaconst, precio_m2, parqueaderos, banios, habitaciones, estrato) %>%
  skim() %>%
  as.data.frame() %>%
  select(-skim_type) %>%
  kable(digits = 2, caption = tab_cap("Resumen estadístico de las principales variables numéricas")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), font_size = 12) %>%
  scroll_box(width = "100%")
Tabla 10. Resumen estadístico de las principales variables numéricas
skim_variable n_missing complete_rate numeric.mean numeric.sd numeric.p0 numeric.p25 numeric.p50 numeric.p75 numeric.p100 numeric.hist
preciom 0 1 433.90 328.67 58.00 220.00 330.00 540.00 1999.00 ▇▂▁▁▁
areaconst 0 1 174.93 142.96 30.00 80.00 123.00 229.00 1745.00 ▇▁▁▁▁
precio_m2 0 1 2.72 1.08 0.15 1.92 2.64 3.38 9.47 ▃▇▂▁▁
parqueaderos 0 1 1.71 1.06 1.00 1.00 1.00 2.00 10.00 ▇▁▁▁▁
banios 0 1 3.13 1.41 1.00 2.00 3.00 4.00 10.00 ▇▇▃▁▁
habitaciones 0 1 3.64 1.43 1.00 3.00 3.00 4.00 10.00 ▂▇▂▁▁
estrato 0 1 4.63 1.03 3.00 4.00 5.00 5.00 6.00 ▅▆▁▇▆

7.2 Composición de la oferta

p1 <- ggplot(vivienda, aes(x = zona, fill = zona)) +
  geom_bar() +
  scale_fill_viridis_d(guide = "none") +
  labs(x = NULL, y = "N° propiedades") +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

p2 <- ggplot(vivienda, aes(x = tipo, fill = tipo)) +
  geom_bar() +
  scale_fill_manual(values = c("Apartamento" = "#2c7fb8", "Casa" = "#d95f0e"), guide = "none") +
  labs(x = NULL, y = "N° propiedades")

gridExtra::grid.arrange(p1, p2, ncol = 2)
Figura 3. Oferta por zona y por tipo de inmueble

Figura 3. Oferta por zona y por tipo de inmueble

La oferta está dominada por la Zona Sur (más de la mitad de los registros) y por los apartamentos (61% frente a 39% de casas). Esta asimetría es relevante: cualquier segmentación o análisis de correspondencia posterior debe interpretarse teniendo en cuenta este desbalance en la composición de la muestra.

vivienda %>%
  count(zona, estrato_f) %>%
  ggplot(aes(x = zona, y = n, fill = estrato_f)) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = percent) +
  scale_fill_viridis_d(name = "Estrato") +
  labs(x = NULL, y = "% de propiedades") +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))
Figura 4. Composición de estratos socioeconómicos por zona

Figura 4. Composición de estratos socioeconómicos por zona

7.3 Distribución del precio

p3 <- ggplot(vivienda, aes(x = preciom)) +
  geom_histogram(bins = 50, fill = "#2c7fb8") +
  labs(x = "Precio (millones COP)", y = "Frecuencia")

p4 <- ggplot(vivienda, aes(x = preciom)) +
  geom_histogram(bins = 50, fill = "#41b6c4") +
  scale_x_log10(labels = comma) +
  labs(x = "Precio (millones COP, log)", y = "Frecuencia")

gridExtra::grid.arrange(p3, p4, ncol = 2)
Figura 5. Distribución del precio, en escala original y en escala logarítmica

Figura 5. Distribución del precio, en escala original y en escala logarítmica

La distribución del precio tiene un fuerte sesgo a la derecha (mediana de 330 millones frente a una media de 433.9 millones); en escala logarítmica se aproxima mucho más a una distribución simétrica. Esto confirma la necesidad de aplicar una transformación logarítmica antes del PCA.

ggplot(vivienda, aes(x = estrato_f, y = preciom, fill = estrato_f)) +
  geom_boxplot(outlier.alpha = 0.3, outlier.size = 0.8) +
  scale_fill_viridis_d(guide = "none") +
  labs(x = "Estrato", y = "Precio (millones COP)")
Figura 6. Precio de venta por estrato socioeconómico

Figura 6. Precio de venta por estrato socioeconómico

ggplot(vivienda, aes(x = reorder(zona, precio_m2, median), y = precio_m2, fill = zona)) +
  geom_boxplot(outlier.alpha = 0.3, outlier.size = 0.8) +
  scale_fill_viridis_d(guide = "none") +
  coord_flip() +
  labs(subtitle = "Métrica de valoración comparable entre propiedades de distinto tamaño",
       x = NULL, y = "Precio por m² (millones COP)")
Figura 7. Precio por metro cuadrado según zona

Figura 7. Precio por metro cuadrado según zona

El estrato muestra una relación monotónica clara con el precio, como es de esperarse. En cuanto al precio por m² (una medida de valoración más comparable que el precio absoluto), se observan diferencias apreciables entre zonas, lo que sugiere que la ubicación aporta valor por encima del tamaño de la propiedad; este patrón se profundiza en el análisis de conglomerados.

7.4 Relación entre variables numéricas

num_vars2 <- c("preciom","areaconst","precio_m2","estrato","parqueaderos","banios","habitaciones")
cor_m <- cor(vivienda[, num_vars2], use = "pairwise.complete.obs")
cor_df <- as.data.frame(as.table(cor_m))

ggplot(cor_df, aes(Var1, Var2, fill = Freq)) +
  geom_tile(color = "white") +
  geom_text(aes(label = round(Freq, 2)), size = 3.5) +
  scale_fill_gradient2(low = "#d73027", mid = "white", high = "#1a9850",
                        midpoint = 0, limits = c(-1,1), name = "Correlación") +
  labs(x = NULL, y = NULL) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
Figura 8. Matriz de correlación de variables numéricas

Figura 8. Matriz de correlación de variables numéricas

preciom correlaciona fuertemente con areaconst, banios y parqueaderos (r > 0.65), y moderadamente con estrato. Este patrón de correlaciones es la base natural para un Análisis de Componentes Principales: existe suficiente estructura de covarianza compartida como para esperar que pocas componentes expliquen una proporción alta de la varianza total.

7.5 Distribución geográfica

ggplot(vivienda, aes(x = longitud, y = latitud, color = zona)) +
  geom_point(alpha = 0.4, size = 0.8) +
  scale_color_viridis_d(name = "Zona") +
  coord_fixed(ratio = 1) +
  labs(x = "Longitud", y = "Latitud") +
  theme(legend.position = "right")
Figura 9. Distribución geográfica de la oferta por zona

Figura 9. Distribución geográfica de la oferta por zona

Las coordenadas confirman una segmentación geográfica coherente por zona (sin solapamientos anómalos), lo que valida el uso de zona como variable categórica confiable para los análisis posteriores.

8 Análisis de Componentes Principales (PCA)

8.1 Objetivo y selección de variables

El objetivo del PCA es reducir la dimensionalidad del conjunto de variables numéricas que describen cada propiedad y encontrar los ejes (componentes) que mejor resumen la variación conjunta del mercado. Se utilizan como variables activas las que describen directamente el tamaño y las prestaciones físicas de la propiedad: preciom, areaconst, parqueaderos, banios, habitaciones y estrato.

Dado el fuerte sesgo detectado en la sección anterior, preciom y areaconst se transforman con logaritmo natural antes de entrar al PCA; esto evita que un pequeño número de propiedades extremadamente caras o grandes domine por sí solas la primera componente. Adicionalmente:

  • precio_m2 se incluye como variable cuantitativa suplementaria: no interviene en el cálculo de los ejes (para evitar redundancia, ya que es una razón de dos variables activas) pero se proyecta sobre ellos para ayudar a interpretarlos.
  • zona y tipo se incluyen como variables cualitativas suplementarias, lo que permite ubicar el “centro de gravedad” de cada zona y tipo de inmueble sobre el plano principal sin que influyan en el cálculo de los componentes.
  • El PCA se calcula sobre la matriz de correlaciones (scale.unit = TRUE), estándar quer cuando las variables activas tienen unidades y escalas muy distintas (millones de pesos, m², conteos de 0 a 10, estrato de 3 a 6).
pca_data <- vivienda %>%
  transmute(
    log_preciom   = log(preciom),
    log_areaconst = log(areaconst),
    parqueaderos  = parqueaderos,
    banios        = banios,
    habitaciones  = habitaciones,
    estrato       = estrato,
    precio_m2     = precio_m2,   # suplementaria cuantitativa
    zona          = zona,        # suplementaria cualitativa
    tipo          = tipo         # suplementaria cualitativa
  ) %>%
  as.data.frame()  # FactoMineR requiere data.frame clásico, no tibble

res.pca <- PCA(pca_data, quanti.sup = 7, quali.sup = c(8, 9),
               scale.unit = TRUE, graph = FALSE)

8.2 Varianza explicada

eig_df <- as.data.frame(res.pca$eig)
names(eig_df) <- c("eigenvalue","pct_var","pct_var_acum")
eig_df$dim <- factor(1:nrow(eig_df))

kable(round(eig_df[,1:3], 2), caption = tab_cap("Valores propios y varianza explicada por componente")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 11. Valores propios y varianza explicada por componente
eigenvalue pct_var pct_var_acum
comp 1 3.70 61.66 61.66
comp 2 1.24 20.66 82.32
comp 3 0.47 7.78 90.10
comp 4 0.27 4.53 94.63
comp 5 0.23 3.78 98.42
ggplot(eig_df, aes(x = dim)) +
  geom_col(aes(y = pct_var), fill = "#2c7fb8") +
  geom_line(aes(y = pct_var_acum, group = 1), color = "#d95f0e", linewidth = 1) +
  geom_point(aes(y = pct_var_acum), color = "#d95f0e", size = 2) +
  geom_text(aes(y = pct_var, label = paste0(round(pct_var,1),"%")), vjust = -0.6, size = 3.3) +
  labs(subtitle = "Barras: % individual · Línea: % acumulado",
       x = "Componente", y = "% de varianza")
Figura 10. Varianza explicada por componente principal del PCA

Figura 10. Varianza explicada por componente principal del PCA

Las dos primeras componentes explican en conjunto 82.3% de la variabilidad total, lo cual es un resultado excelente para un conjunto de 6 variables activas: confirma la fuerte estructura de correlación detectada previamente y permite resumir el mercado en un plano de solo dos dimensiones sin pérdida sustancial de información.

8.3 Círculo de correlaciones (variables activas)

var_coord <- as.data.frame(res.pca$var$coord[,1:2])
var_coord$var <- rownames(var_coord)
circle <- data.frame(x = cos(seq(0, 2*pi, length.out = 100)),
                      y = sin(seq(0, 2*pi, length.out = 100)))

ggplot() +
  geom_path(data = circle, aes(x, y), color = "grey70") +
  geom_hline(yintercept = 0, linetype = 2, color = "grey70") +
  geom_vline(xintercept = 0, linetype = 2, color = "grey70") +
  geom_segment(data = var_coord, aes(x = 0, y = 0, xend = Dim.1, yend = Dim.2),
               arrow = arrow(length = unit(0.22, "cm")), color = "#2c7fb8", linewidth = 0.8) +
  geom_text(data = var_coord, aes(x = Dim.1*1.12, y = Dim.2*1.12, label = var),
            size = 4, fontface = "bold", color = "#08306b") +
  coord_fixed() + xlim(-1.1,1.1) + ylim(-1.1,1.1) +
  labs(x = paste0("Dim 1 (", round(res.pca$eig[1,2],1), "%)"),
       y = paste0("Dim 2 (", round(res.pca$eig[2,2],1), "%)"))
Figura 11. Círculo de correlaciones de las variables activas del PCA

Figura 11. Círculo de correlaciones de las variables activas del PCA

data.frame(
  variable = rownames(res.pca$var$coord),
  cos2_Dim1 = round(res.pca$var$cos2[,1],3),
  cos2_Dim2 = round(res.pca$var$cos2[,2],3),
  contrib_Dim1 = round(res.pca$var$contrib[,1],1),
  contrib_Dim2 = round(res.pca$var$contrib[,2],1)
) %>%
  arrange(desc(contrib_Dim1)) %>%
  kable(caption = tab_cap("Calidad de representación (cos²) y contribución (%) de cada variable")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 12. Calidad de representación (cos²) y contribución (%) de cada variable
variable cos2_Dim1 cos2_Dim2 contrib_Dim1 contrib_Dim2
log_preciom log_preciom 0.854 0.048 23.1 3.8
log_areaconst log_areaconst 0.795 0.052 21.5 4.2
banios banios 0.776 0.036 21.0 2.9
parqueaderos parqueaderos 0.585 0.049 15.8 4.0
estrato estrato 0.381 0.472 10.3 38.1
habitaciones habitaciones 0.309 0.582 8.4 47.0

Interpretación de los ejes:

  • Dimensión 1 (61.7%, “Tamaño / valor global”): todas las variables activas cargan positivamente y con magnitud alta (log_preciom, log_areaconst y baños por encima de 0.85). Es, en esencia, un índice general de valor de la propiedad: a mayor coordenada en Dim 1, mayor precio, área, baños, parqueaderos y estrato simultáneamente.
  • Dimensión 2 (20.7%, “Habitaciones vs. estrato/precio por m²”): habitaciones carga fuertemente positivo mientras que estrato carga negativo; la variable suplementaria precio_m2 también carga negativo en este eje. Este componente contrasta viviendas grandes en número de habitaciones pero de estrato y precio por m² más bajos (parte positiva) frente a viviendas compactas, de estrato alto y precio por m² elevado (parte negativa): un eje que separa el valor por “cantidad de espacio” del valor por “ubicación/categoría”.

8.4 Mapa de individuos y variables suplementarias

ind_coord <- as.data.frame(res.pca$ind$coord[,1:2])
ind_coord$zona <- vivienda$zona
ind_coord$tipo <- vivienda$tipo

quali_sup <- as.data.frame(res.pca$quali.sup$coord[,1:2])
quali_sup$categoria <- rownames(quali_sup)

ggplot(ind_coord, aes(Dim.1, Dim.2)) +
  geom_point(aes(color = zona), alpha = 0.25, size = 0.7) +
  geom_point(data = quali_sup, aes(Dim.1, Dim.2), size = 4, shape = 18, color = "black") +
  ggrepel::geom_text_repel(data = quali_sup, aes(Dim.1, Dim.2, label = categoria),
                            fontface = "bold", size = 3.6, bg.color = "white", bg.r = 0.15) +
  scale_color_viridis_d(name = "Zona") +
  coord_fixed() +
  labs(subtitle = "Rombos negros: centroides de zona y tipo (categorías suplementarias)",
       x = paste0("Dim 1 (", round(res.pca$eig[1,2],1), "%)"),
       y = paste0("Dim 2 (", round(res.pca$eig[2,2],1), "%)"))
Figura 12. Mapa de individuos coloreado por zona, con centroides de zona y tipo

Figura 12. Mapa de individuos coloreado por zona, con centroides de zona y tipo

Los centroides de zona confirman la lectura del círculo de correlaciones: Zona Oeste se ubica en el extremo de mayor Dim 1 (propiedades de mayor valor global), mientras que Zona Centro y Zona Oriente se ubican en la parte alta de Dim 2 (más habitaciones, menor precio por m²), coherente con la mayor presencia de casas grandes tradicionales en esas zonas. Las categorías Casa y Apartamento también se separan claramente en Dim 2, con las casas del lado de “más habitaciones” y los apartamentos del lado de “precio por m² más alto”.

data.frame(
  variable = c("precio_m2 (cuantitativa sup.)", rownames(res.pca$quali.sup$coord)),
  Dim1 = round(c(res.pca$quanti.sup$coord[1,1], res.pca$quali.sup$coord[,1]), 3),
  Dim2 = round(c(res.pca$quanti.sup$coord[1,2], res.pca$quali.sup$coord[,2]), 3)
) %>%
  kable(caption = tab_cap("Coordenadas de variables/categorías suplementarias en el plano principal")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 13. Coordenadas de variables/categorías suplementarias en el plano principal
variable Dim1 Dim2
precio_m2 (cuantitativa sup.) 0.130 -0.659
Zona Centro Zona Centro -0.468 1.619
Zona Norte Zona Norte -0.521 0.201
Zona Oeste Zona Oeste 0.972 -0.773
Zona Oriente Zona Oriente -0.823 1.859
Zona Sur Zona Sur 0.039 -0.066
Apartamento Apartamento -0.700 -0.441
Casa Casa 1.108 0.699

9 Análisis de Conglomerados

9.1 Metodología

Se utiliza Clasificación Jerárquica sobre Componentes Principales (HCPC), implementada en FactoMineR::HCPC(). Esta técnica agrupa las propiedades mediante un clustering jerárquico de Ward aplicado directamente sobre las coordenadas del PCA (en lugar de sobre las variables originales), lo cual tiene dos ventajas relevantes para este caso: (1) el “ruido” de alta dimensionalidad ya fue filtrado por el PCA, y (2) las componentes son ortogonales entre sí, evitando que variables correlacionadas (como precio y área) pesen doble en la distancia euclidiana usada para agrupar.

set.seed(123)
res.hcpc <- HCPC(res.pca, nb.clust = -1, min = 3, max = 8, graph = FALSE)
vivienda$cluster <- factor(res.hcpc$data.clust$clust)

9.2 Selección del número de conglomerados

El número de clusters se determina de forma automática y objetiva maximizando la ganancia relativa de inercia inter-grupos en las últimas fusiones del árbol jerárquico (el “codo” de la curva). El siguiente gráfico muestra la pérdida de inercia asociada a fusionar clusters en cada paso:

gain <- head(res.hcpc$call$t$inert.gain, 12)
elbow_df <- data.frame(k = 2:(length(gain)+1), gain = gain)
k_sel <- length(unique(vivienda$cluster))

ggplot(elbow_df, aes(k, gain)) +
  geom_line(color = "#2c7fb8") +
  geom_point(size = 2.2, color = "#2c7fb8") +
  geom_vline(xintercept = k_sel, linetype = 2, color = "#d95f0e") +
  annotate("text", x = k_sel + 0.3, y = max(elbow_df$gain)*0.85,
            label = paste0("k = ", k_sel, " seleccionado"), color = "#d95f0e", hjust = 0) +
  scale_x_continuous(breaks = elbow_df$k) +
  labs(subtitle = "El punto donde la curva se aplana ('codo') indica la partición más informativa",
       x = "Número de clusters (k)", y = "Ganancia de inercia")
Figura 13. Ganancia de inercia según número de clusters (método del codo)

Figura 13. Ganancia de inercia según número de clusters (método del codo)

El criterio automático selecciona 6 conglomerados: la ganancia de inercia cae abruptamente después de este punto, indicando que agregar más grupos aporta cada vez menos información nueva sobre la estructura de los datos.

9.3 Mapa de conglomerados sobre el plano principal

clust_map <- as.data.frame(res.pca$ind$coord[,1:2])
clust_map$cluster <- vivienda$cluster

centroides <- clust_map %>% group_by(cluster) %>%
  summarise(Dim.1 = mean(Dim.1), Dim.2 = mean(Dim.2))

ggplot(clust_map, aes(Dim.1, Dim.2, color = cluster)) +
  geom_point(alpha = 0.25, size = 0.7) +
  stat_ellipse(level = 0.68, linewidth = 0.8) +
  geom_point(data = centroides, aes(Dim.1, Dim.2), color = "black", size = 3, shape = 4, stroke = 1.3) +
  scale_color_manual(values = pal6, name = "Cluster") +
  coord_fixed() +
  labs(subtitle = "Elipses de concentración al 68% · X negra: centroide de cada cluster",
       x = paste0("Dim 1 (", round(res.pca$eig[1,2],1), "%)"),
       y = paste0("Dim 2 (", round(res.pca$eig[2,2],1), "%)"))
Figura 14. Segmentos identificados sobre el plano de componentes principales

Figura 14. Segmentos identificados sobre el plano de componentes principales

9.4 Perfil de cada segmento

perfil <- vivienda %>%
  group_by(cluster) %>%
  summarise(
    n = n(),
    pct = round(100*n()/nrow(vivienda),1),
    precio_medio_M = round(mean(preciom)),
    area_media_m2 = round(mean(areaconst)),
    precio_m2_medio = round(mean(precio_m2),2),
    habitaciones_prom = round(mean(habitaciones),1),
    banios_prom = round(mean(banios),1),
    parqueaderos_prom = round(mean(parqueaderos),1),
    estrato_prom = round(mean(estrato),1),
    tipo_dominante = names(sort(table(tipo), decreasing = TRUE))[1],
    zona_dominante = names(sort(table(zona), decreasing = TRUE))[1]
  )

kable(perfil, caption = tab_cap("Perfil promedio de cada segmento identificado")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 14. Perfil promedio de cada segmento identificado
cluster n pct precio_medio_M area_media_m2 precio_m2_medio habitaciones_prom banios_prom parqueaderos_prom estrato_prom tipo_dominante zona_dominante
1 2187 26.3 166 72 2.37 2.8 1.9 1.0 3.6 Apartamento Zona Sur
2 2256 27.1 319 103 3.19 2.9 2.5 1.4 5.1 Apartamento Zona Sur
3 913 11.0 344 217 1.77 4.5 3.2 1.3 3.7 Casa Zona Sur
4 1824 21.9 688 228 3.31 3.7 4.1 2.2 5.6 Apartamento Zona Sur
5 475 5.7 502 340 1.61 7.6 5.1 1.6 3.9 Casa Zona Sur
6 664 8.0 1084 436 2.77 4.6 5.3 4.3 5.7 Casa Zona Sur
prof_z <- vivienda %>%
  select(cluster, preciom, areaconst, precio_m2, habitaciones, banios, parqueaderos, estrato) %>%
  mutate(across(-cluster, ~ as.numeric(scale(.)))) %>%
  group_by(cluster) %>%
  summarise(across(everything(), mean)) %>%
  pivot_longer(-cluster, names_to = "variable", values_to = "z")

ggplot(prof_z, aes(x = variable, y = cluster, fill = z)) +
  geom_tile(color = "white") +
  geom_text(aes(label = round(z,2)), size = 3.4) +
  scale_fill_gradient2(low = "#d73027", mid = "white", high = "#1a9850", midpoint = 0, name = "z-score") +
  labs(subtitle = "Valores en desviaciones estándar respecto a la media general (0 = promedio del mercado)",
       x = NULL, y = NULL) +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))
Figura 15. Perfil estandarizado (z-score) de los segmentos identificados

Figura 15. Perfil estandarizado (z-score) de los segmentos identificados

9.5 Distribución geográfica de los segmentos

ggplot(vivienda, aes(x = longitud, y = latitud, color = cluster)) +
  geom_point(alpha = 0.35, size = 0.7) +
  scale_color_manual(values = pal6, name = "Cluster") +
  coord_fixed(ratio = 1) +
  labs(x = "Longitud", y = "Latitud")
Figura 16. Distribución geográfica de los segmentos identificados

Figura 16. Distribución geográfica de los segmentos identificados

9.6 Interpretación de negocio de los segmentos

Con base en el perfil promedio, el mapa geográfico y las variables descriptivas más asociadas a cada grupo (v.test del procedimiento HCPC), se proponen las siguientes etiquetas de negocio:

nombres_cluster <- data.frame(
  cluster = levels(vivienda$cluster),
  Segmento = c(
    "Económico / entrada al mercado",
    "Apartamentos premium compactos",
    "Casas grandes de buen valor",
    "Alto estrato integral (Zona Oeste)",
    "Casas multi-habitación / gran formato",
    "Casas exclusivas multi-parqueadero"
  ),
  Descripcion = c(
    "Apartamentos pequeños y económicos, estrato bajo-medio, concentrados en Zona Norte y Oriente. El punto de entrada más accesible del mercado.",
    "Apartamentos de área reducida pero precio por m² elevado y estrato alto, principalmente en Zona Sur: la vivienda compacta 'premium'.",
    "Casas con área y número de habitaciones por encima del promedio, pero precio por m² y estrato más bajos: la opción de mejor relación espacio/precio, en Zona Oriente y Centro.",
    "El segmento de mayor valor en todas las dimensiones (precio, área, baños, parqueaderos, estrato), fuertemente concentrado en Zona Oeste: el segmento de lujo transversal.",
    "Casas con un número de habitaciones muy por encima del resto del mercado (promedio superior a 7), estrato medio-bajo y precio por m² reducido: posible uso familiar extendido o rentístico.",
    "Casas con muchos parqueaderos (promedio de 4), precio, área, baños y estrato altos, concentradas en Zona Sur: viviendas exclusivas orientadas a hogares con varios vehículos."
  )
)
kable(nombres_cluster, caption = tab_cap("Interpretación de negocio de los seis segmentos")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 15. Interpretación de negocio de los seis segmentos
cluster Segmento Descripcion
1 Económico / entrada al mercado Apartamentos pequeños y económicos, estrato bajo-medio, concentrados en Zona Norte y Oriente. El punto de entrada más accesible del mercado.
2 Apartamentos premium compactos Apartamentos de área reducida pero precio por m² elevado y estrato alto, principalmente en Zona Sur: la vivienda compacta ‘premium’.
3 Casas grandes de buen valor Casas con área y número de habitaciones por encima del promedio, pero precio por m² y estrato más bajos: la opción de mejor relación espacio/precio, en Zona Oriente y Centro.
4 Alto estrato integral (Zona Oeste) El segmento de mayor valor en todas las dimensiones (precio, área, baños, parqueaderos, estrato), fuertemente concentrado en Zona Oeste: el segmento de lujo transversal.
5 Casas multi-habitación / gran formato Casas con un número de habitaciones muy por encima del resto del mercado (promedio superior a 7), estrato medio-bajo y precio por m² reducido: posible uso familiar extendido o rentístico.
6 Casas exclusivas multi-parqueadero Casas con muchos parqueaderos (promedio de 4), precio, área, baños y estrato altos, concentradas en Zona Sur: viviendas exclusivas orientadas a hogares con varios vehículos.
saveRDS(vivienda, "data/vivienda_cluster.rds")
saveRDS(res.pca, "data/res_pca.rds")
saveRDS(res.hcpc, "data/res_hcpc.rds")

10 Análisis de Correspondencias

10.1 Objetivo, elementos de la técnica y variables

El Análisis de Correspondencias (CA) estudia la asociación entre dos variables categóricas a partir de su tabla de contingencia; su extensión a más de dos variables es el Análisis de Correspondencias Múltiples (MCA). Los elementos constitutivos de la técnica, que se reportan explícitamente en cada aplicación siguiente, son:

  • Tabla de contingencia: cruce de frecuencias observadas entre las dos variables.
  • Prueba χ² de independencia: contrasta si existe asociación estadísticamente significativa entre las variables (H₀: independencia).
  • Perfiles fila/columna: distribuciones condicionales que se comparan mediante la distancia chi-cuadrado, la métrica propia del CA.
  • Inercia total: equivalente al estadístico χ² normalizado por el tamaño de la muestra; mide cuánta asociación hay para repartir entre los ejes.
  • Valores propios y % de varianza (inercia) explicada por cada dimensión.
  • Coordenadas, contribuciones y cos² de filas y columnas (análogas a las del PCA): la contribución indica qué categorías construyen cada eje, y el cos² indica qué tan bien queda representada cada categoría en el plano.
  • Rango de la tabla: el número máximo de dimensiones no triviales de un CA es min(filas - 1, columnas - 1). Es importante reconocerlo porque cuando una de las dos variables es binaria (como tipo), el análisis queda forzosamente reducido a una sola dimensión.

En esta sección se examinan las relaciones entre zona, tipo, estrato y barrio (esta última, normalizada en la sección de limpieza y reducida a sus 20 categorías más frecuentes para su uso en CA, dado que con 380+ barrios distintos un mapa de correspondencias sería ilegible y estadísticamente poco robusto por el bajo tamaño muestral de la mayoría de barrios).

10.2 CA: Zona × Tipo de inmueble

tab1 <- table(vivienda$zona, vivienda$tipo)
kable(tab1, caption = tab_cap("Tabla de contingencia: zona x tipo de inmueble")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 16. Tabla de contingencia: zona x tipo de inmueble
Apartamento Casa
Zona Centro 24 100
Zona Norte 1198 722
Zona Oeste 1029 169
Zona Oriente 62 289
Zona Sur 2787 1939
chi1 <- chisq.test(tab1)

La prueba chi-cuadrado de independencia da χ² = 690.9, gl = 4, p < 0.001: se rechaza la independencia entre zona y tipo de inmueble: la composición casa/apartamento sí varía significativamente según la zona.

res.ca1 <- CA(tab1, graph = FALSE)

Como tipo tiene solo 2 categorías, el rango de la tabla es min(5-1, 2-1) = 1: el CA de zona × tipo tiene una única dimensión, que concentra por definición el 100% de la inercia.

d1  <- data.frame(cat = rownames(tab1), Dim1 = as.numeric(res.ca1$row$coord), grupo = "Zona")
d1b <- data.frame(cat = colnames(tab1), Dim1 = as.numeric(res.ca1$col$coord), grupo = "Tipo")
d1all <- bind_rows(d1, d1b)

ggplot(d1all, aes(x = reorder(cat, Dim1), y = Dim1, fill = grupo)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c("Zona" = "#2c7fb8", "Tipo" = "#d95f0e")) +
  labs(x = NULL, y = "Coordenada Dim 1", fill = NULL)
Figura 17. CA zona x tipo: única dimensión (100% de la inercia)

Figura 17. CA zona x tipo: única dimensión (100% de la inercia)

Interpretación: en un CA de una sola dimensión, dos categorías se asocian cuando quedan del mismo lado del cero. Apartamento se asocia con Zona Oeste (ambos positivos), mientras que Casa se asocia con Zona Centro y Zona Oriente (ambos negativos); Zona Norte y Zona Sur quedan cerca del centro, es decir, sin una asociación marcada con ningún tipo, coherente con ser las zonas más grandes y de oferta más mixta.

10.3 CA: Zona × Estrato socioeconómico

tab2 <- table(vivienda$zona, vivienda$estrato_f)
kable(tab2, caption = tab_cap("Tabla de contingencia: zona x estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 17. Tabla de contingencia: zona x estrato
3 4 5 6
Zona Centro 105 14 4 1
Zona Norte 572 407 769 172
Zona Oeste 54 84 290 770
Zona Oriente 340 8 2 1
Zona Sur 382 1616 1685 1043
chi2 <- chisq.test(tab2)
res.ca2 <- CA(tab2, graph = FALSE)
eig2 <- as.data.frame(res.ca2$eig)
names(eig2) <- c("eigenvalue","pct_var","pct_var_acum")
kable(round(eig2,2), caption = tab_cap("CA zona x estrato: inercia por dimensión")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 18. CA zona x estrato: inercia por dimensión
eigenvalue pct_var pct_var_acum
dim 1 0.32 69.97 69.97
dim 2 0.13 27.68 97.65
dim 3 0.01 2.35 100.00

χ² = 3830.4, gl = 12, p < 0.001: la asociación entre zona y estrato es muy fuerte y altamente significativa. Con un rango de min(5-1,4-1) = 3 dimensiones, las dos primeras capturan 97.6% de la inercia total (un plano 2D es suficientemente representativo).

row2 <- as.data.frame(res.ca2$row$coord[,1:2]); row2$cat <- rownames(row2); row2$grupo <- "Zona"
col2 <- as.data.frame(res.ca2$col$coord[,1:2]); col2$cat <- rownames(col2); col2$grupo <- "Estrato"
names(row2)[1:2] <- c("Dim1","Dim2"); names(col2)[1:2] <- c("Dim1","Dim2")

ggplot() +
  geom_hline(yintercept = 0, linetype = 2, color = "grey70") +
  geom_vline(xintercept = 0, linetype = 2, color = "grey70") +
  geom_point(data = row2, aes(Dim1, Dim2), color = "#2c7fb8", size = 3) +
  ggrepel::geom_text_repel(data = row2, aes(Dim1, Dim2, label = cat), color = "#2c7fb8", fontface = "bold", size = 4.2) +
  geom_point(data = col2, aes(Dim1, Dim2), color = "#d95f0e", size = 3, shape = 17) +
  ggrepel::geom_text_repel(data = col2, aes(Dim1, Dim2, label = cat), color = "#d95f0e", fontface = "bold", size = 4.2) +
  coord_fixed() +
  labs(x = paste0("Dim 1 (", round(eig2$pct_var[1],1), "%)"),
       y = paste0("Dim 2 (", round(eig2$pct_var[2],1), "%)"))
Figura 18. Mapa simétrico CA: zona x estrato

Figura 18. Mapa simétrico CA: zona x estrato

Interpretación: el mapa reproduce con precisión estadística lo observado en la exploración descriptiva: Zona Oeste se asocia fuertemente con estrato 6, Zona Sur con estratos 4 y 5, y Zona Centro / Zona Oriente con estrato 3. La proximidad en el mapa simétrico entre una zona y un estrato indica que esa combinación aparece con una frecuencia mucho mayor a la esperada bajo independencia.

10.4 Calidad de barrio y CA: Barrios (top 20) × Estrato

top_barrios <- names(sort(table(vivienda$barrio), decreasing = TRUE))[1:20]
sub_barrio <- vivienda %>% filter(barrio %in% top_barrios) %>% mutate(barrio = droplevels(barrio))

tab3 <- table(sub_barrio$barrio, sub_barrio$estrato_f)
kable(tab3, caption = tab_cap("Tabla de contingencia: 20 barrios más frecuentes x estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), font_size = 12) %>%
  scroll_box(width = "100%")
Tabla 19. Tabla de contingencia: 20 barrios más frecuentes x estrato
3 4 5 6
acopi 21 51 59 27
aguacatal 20 24 4 61
brisas de los 80 2 0 0
caney 1 69 17 1
ciudad 2000 10 84 2 0
ciudad jardin 2 4 67 467
cristales 0 1 25 57
el caney 9 166 34 0
el ingenio 0 9 157 37
el limonar 6 36 91 2
el refugio 5 87 28 0
la flora 1 33 328 6
la hacienda 2 29 134 1
los cristales 0 3 44 107
normandia 1 0 23 135
pance 0 2 20 390
prados del norte 3 74 50 0
santa teresita 0 4 27 232
urbanizacion la flora 0 3 80 0
valle del lili 4 577 423 5
chi3 <- suppressWarnings(chisq.test(tab3))
res.ca3 <- CA(tab3, graph = FALSE)
eig3 <- as.data.frame(res.ca3$eig)
names(eig3) <- c("eigenvalue","pct_var","pct_var_acum")

Estos 20 barrios concentran 54.9% de toda la oferta. La prueba chi-cuadrado (χ² = 6870, gl = 57, p < 0.001) confirma una asociación muy fuerte entre barrio y estrato: de hecho, el estrato de una propiedad en esta ciudad está determinado en gran medida por el barrio donde se ubica, no solo por la zona. Las dos primeras dimensiones explican 83.1% de la inercia.

ca3_row <- as.data.frame(res.ca3$row$coord[,1:2]); ca3_row$cat <- rownames(ca3_row)
ca3_col <- as.data.frame(res.ca3$col$coord[,1:2]); ca3_col$cat <- rownames(ca3_col)
names(ca3_row)[1:2] <- c("Dim1","Dim2"); names(ca3_col)[1:2] <- c("Dim1","Dim2")

set.seed(42)
ggplot() +
  geom_hline(yintercept = 0, linetype = 2, color = "grey70") +
  geom_vline(xintercept = 0, linetype = 2, color = "grey70") +
  geom_point(data = ca3_row, aes(Dim1, Dim2), color = "#2c7fb8", size = 2.2) +
  ggrepel::geom_text_repel(data = ca3_row, aes(Dim1, Dim2, label = cat), size = 3.4, color = "#2c7fb8",
                            max.overlaps = Inf, force = 3, box.padding = 0.4, segment.size = 0.3) +
  geom_point(data = ca3_col, aes(Dim1, Dim2), color = "#d95f0e", size = 4.5, shape = 17) +
  ggrepel::geom_text_repel(data = ca3_col, aes(Dim1, Dim2, label = cat), size = 4.5, fontface = "bold",
                            color = "#d95f0e", max.overlaps = Inf, force = 3) +
  coord_fixed() +
  labs(x = paste0("Dim 1 (", round(eig3$pct_var[1],1), "%)"),
       y = paste0("Dim 2 (", round(eig3$pct_var[2],1), "%)"))
Figura 19. CA de los 20 barrios más frecuentes x estrato socioeconómico

Figura 19. CA de los 20 barrios más frecuentes x estrato socioeconómico

Interpretación: barrios como valle del lili, caney y el caney se ubican del lado del estrato 4, pance, normandia y santa teresita se asocian con estrato 6, mientras que brisas de los y ciudad 2000 quedan cerca de estrato 3. Este nivel de detalle geográfico-socioeconómico es directamente accionable: permite a la empresa dirigir campañas de captación de inmuebles o de compradores por barrio y no solo por zona, con mayor precisión.

10.5 Análisis de Correspondencias Múltiples (MCA): visión integrada

Para cerrar el análisis de variables categóricas se calcula un MCA (la generalización del CA a más de dos variables, basada en la matriz disyuntiva completa, o equivalentemente en la matriz de Burt) sobre zona, tipo y estrato de forma simultánea, proyectando además el cluster obtenido en el análisis de conglomerados como variable ilustrativa (no interviene en el cálculo de los ejes, solo se ubica sobre ellos). Esto permite verificar, con una técnica distinta a las anteriores, que la segmentación obtenida por conglomerados es consistente con la estructura categórica del mercado.

mca_data <- vivienda %>%
  transmute(zona, tipo, estrato_f, cluster) %>%
  as.data.frame()

res.mca <- MCA(mca_data, quali.sup = 4, graph = FALSE)
eig_mca <- as.data.frame(res.mca$eig)
names(eig_mca) <- c("eigenvalue","pct_var","pct_var_acum")
kable(round(head(eig_mca,5),2), caption = tab_cap("MCA: inercia por dimensión (primeras 5)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 20. MCA: inercia por dimensión (primeras 5)
eigenvalue pct_var pct_var_acum
dim 1 0.56 21.08 21.08
dim 2 0.45 16.99 38.07
dim 3 0.38 14.24 52.31
dim 4 0.33 12.50 64.81
dim 5 0.32 12.12 76.94

Las dos primeras dimensiones explican 38.1% de la inercia, un porcentaje menor al del CA simple, lo cual es normal en MCA (con más variables categóricas, la inercia total se reparte entre más dimensiones; el punto de referencia no es el % absoluto sino su comparación con 1/número de variables activas).

var_coord <- as.data.frame(res.mca$var$coord[,1:2])
var_coord$cat <- rownames(var_coord)
var_coord$grupo <- sub("_.*$", "", rownames(res.mca$var$coord))
names(var_coord)[1:2] <- c("Dim1","Dim2")

clust_sup <- as.data.frame(res.mca$quali.sup$coord[,1:2])
clust_sup$cat <- rownames(clust_sup)
names(clust_sup)[1:2] <- c("Dim1","Dim2")

ggplot() +
  geom_hline(yintercept = 0, linetype = 2, color = "grey70") +
  geom_vline(xintercept = 0, linetype = 2, color = "grey70") +
  geom_point(data = var_coord, aes(Dim1, Dim2, color = grupo), size = 3) +
  ggrepel::geom_text_repel(data = var_coord, aes(Dim1, Dim2, label = cat, color = grupo), fontface = "bold", size = 3.8) +
  geom_point(data = clust_sup, aes(Dim1, Dim2), color = "black", size = 3, shape = 4, stroke = 1.4) +
  ggrepel::geom_text_repel(data = clust_sup, aes(Dim1, Dim2, label = cat), color = "black", fontface = "italic", size = 3.6) +
  scale_color_manual(values = c("Zona"="#2c7fb8","tipo"="#d95f0e","estrato"="#1a9850")) +
  coord_fixed() +
  labs(x = paste0("Dim 1 (", round(eig_mca$pct_var[1],1), "%)"),
       y = paste0("Dim 2 (", round(eig_mca$pct_var[2],1), "%)"),
       color = NULL)
Figura 20. Mapa MCA de zona, tipo y estrato, con el cluster como variable ilustrativa

Figura 20. Mapa MCA de zona, tipo y estrato, con el cluster como variable ilustrativa

Interpretación: los clusters obtenidos en el análisis de conglomerados (marcados con “×” negras) se ubican en el plano MCA en posiciones consistentes con su perfil ya conocido: el cluster_4 (alto estrato integral, Zona Oeste) aparece del lado de Zona Oeste y estrato_f_6, mientras que cluster_1 y cluster_2 (segmentos económicos/apartamentos) se agrupan del lado de Apartamento y los estratos más bajos. Esta coincidencia entre dos técnicas completamente distintas (clustering sobre variables cuantitativas vs. MCA sobre variables categóricas) es una forma de validación cruzada de la segmentación: ambas convergen en la misma lectura del mercado.

saveRDS(vivienda, "data/vivienda_final.rds")

11 Síntesis integrada de los hallazgos

Las tres técnicas aplicadas son complementarias y convergen en una misma lectura del mercado:

  • El PCA mostró que el mercado se organiza en torno a un eje dominante de valor global y un eje secundario que separa tamaño de categoría/ ubicación.
  • El análisis de conglomerados tradujo ese espacio continuo en 6 segmentos discretos y accionables, cada uno con un perfil de precio, tamaño y ubicación bien definido.
  • El análisis de correspondencias confirmó, con pruebas de hipótesis formales (χ², todas con p < 0.001), que la ubicación geográfica (zona y, de forma aún más precisa, barrio) está fuertemente asociada al estrato socioeconómico de la oferta, y que esa asociación es coherente con los segmentos hallados por clustering.

En conjunto, estos resultados indican que la ubicación (zona/barrio) es la variable organizadora principal del mercado, seguida del tamaño de la propiedad; el precio es, en gran medida, una función combinada de ambas.

12 Conclusiones generales

  1. El conjunto de datos, tras un proceso de limpieza acotado y estadísticamente justificado (3 registros descartados, 0% del total, más imputación condicional para tres variables de conteo), resultó apto y robusto para análisis multivariados.
  2. Dos componentes principales explican 82.3% de la variación del mercado, evidenciando una estructura de correlación fuerte entre precio, área, baños, parqueaderos y estrato.
  3. El mercado se segmenta naturalmente en 6 grupos con identidades de negocio claras, desde vivienda económica de entrada hasta el segmento de lujo de Zona Oeste.
  4. Zona, barrio y estrato están asociados de forma estadísticamente significativa y de fuerte magnitud; el barrio permite una segmentación geográfica más fina que la zona.
  5. Las tres técnicas (PCA, conglomerados y correspondencias) convergen en una misma narrativa del mercado, lo que refuerza la confiabilidad de los hallazgos para sustentar decisiones de negocio.

13 Recomendaciones estratégicas

Con base en los hallazgos anteriores, se recomienda a la dirección de la empresa:

  1. Adoptar la segmentación de 6 clusters como esquema comercial base: definir estrategias diferenciadas de captación, precios y comunicación para cada segmento (por ejemplo, financiamiento facilitado para el segmento “económico / entrada al mercado”, y servicios premium para el segmento “alto estrato integral”).
  2. Usar precio_m2 segmentado por zona/barrio, y no el precio absoluto, como referencia de valoración: dado que precio y área están altamente correlacionados, comparar propiedades por precio por m² dentro de su zona/barrio evita errores de sobrevaloración o subvaloración.
  3. Priorizar la captación de inventario en barrios con alto precio por m² y baja oferta relativa (identificables cruzando el mapa de conglomerados geográfico con el análisis de correspondencias de barrios), donde el poder de fijación de precios de la empresa es mayor.
  4. Usar el barrio, no solo la zona, como unidad de segmentación de marketing: el Análisis de Correspondencias mostró que el barrio explica el estrato con mayor precisión que la zona, por lo que campañas dirigidas por barrio deberían tener mejor retorno que campañas por zona.
  5. Estandarizar la captura de datos en el proceso comercial (parqueaderos, piso, nombre de barrio): las inconsistencias encontradas (barrio con variantes de texto, ~19% de parqueaderos sin reportar) sugieren que mejorar el formulario de captura de anuncios propios de la empresa mejoraría la calidad de futuros análisis y reduciría el riesgo de decisiones basadas en datos imputados.
  6. Monitorear periódicamente los seis segmentos: dado que este análisis es una fotografía del mercado en un momento dado, se recomienda repetir el pipeline (documentado y reproducible en este informe) trimestral o semestralmente para detectar desplazamientos de precio o composición de oferta entre zonas.

14 Limitaciones del estudio

  • Los datos corresponden a anuncios publicados, no a transacciones efectivamente cerradas; el precio de oferta puede diferir del precio final de venta.
  • La información es una fotografía transversal (no longitudinal): no es posible con estos datos estimar tendencias temporales de precios.
  • Variables potencialmente relevantes para el valor de una propiedad (antigüedad, estado de conservación, cercanía a vías principales o transporte público, amenidades del conjunto) no están disponibles en la base de datos.
  • El campo barrio, aun normalizado, depende de cómo cada anunciante escribió el nombre; pueden persistir agrupaciones imperfectas para barrios con nombres muy similares o poco frecuentes.
  • Los resultados del Análisis de Correspondencias sobre barrios se limitan a los 20 más frecuentes por razones de robustez estadística y legibilidad visual; barrios de baja frecuencia no están representados individualmente.

15 Referencias

  • Husson, F., Lê, S., & Pagès, J. (2017). Exploratory Multivariate Analysis by Example Using R (2nd ed.). Chapman and Hall/CRC.
  • Greenacre, M. (2017). Correspondence Analysis in Practice (3rd ed.). Chapman and Hall/CRC.
  • Lê, S., Josse, J., & Husson, F. (2008). FactoMineR: An R Package for Multivariate Analysis. Journal of Statistical Software, 25(1), 1-18.
  • R Core Team (R version 4.5.2 (2025-10-31 ucrt)). R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing.
  • Wickham, H. et al. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686.
  • Fuente de datos: paquete paqueteMODELOS (repositorio de GitHub dgonxalex80/paqueteMODELOS), datos obtenidos mediante web scraping de anuncios publicados en OLX.

16 Anexos

16.1 Anexo A: Diccionario de variables

dic <- data.frame(
  Variable = c("id","zona","piso","estrato","preciom","areaconst","parqueaderos",
               "banios","habitaciones","tipo","barrio","longitud","latitud",
               "piso_cat","estrato_f","precio_m2","cluster"),
  Tipo = c("Numérica (id)","Categórica","Categórica (texto)","Numérica ordinal",
           "Numérica continua","Numérica continua","Numérica discreta",
           "Numérica discreta","Numérica discreta","Categórica","Categórica (texto)",
           "Numérica continua","Numérica continua","Categórica (derivada)",
           "Categórica ordinal (derivada)","Numérica continua (derivada)","Categórica (derivada)"),
  Descripcion = c(
    "Identificador único de la propiedad",
    "Zona geográfica de la ciudad",
    "Piso del inmueble (variable original, con NA frecuentes)",
    "Estrato socioeconómico (3 a 6)",
    "Precio de venta, en millones de pesos (COP)",
    "Área construida, en m²",
    "Número de parqueaderos (imputado por mediana tipo x estrato)",
    "Número de baños (0 recodificado a NA e imputado)",
    "Número de habitaciones (0 recodificado a NA e imputado)",
    "Tipo de inmueble: Casa / Apartamento",
    "Barrio (normalizado: minúsculas, sin tildes)",
    "Coordenada geográfica de longitud",
    "Coordenada geográfica de latitud",
    "Piso con categoría explícita 'No reportado'",
    "Estrato como factor ordenado",
    "Precio / área construida (millones COP por m²)",
    "Segmento asignado por el análisis de conglomerados (HCPC), 6 niveles"
  )
)
kable(dic, caption = tab_cap("Diccionario de variables del dataset final")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), font_size = 12)
Tabla 21. Diccionario de variables del dataset final
Variable Tipo Descripcion
id Numérica (id) Identificador único de la propiedad
zona Categórica Zona geográfica de la ciudad
piso Categórica (texto) Piso del inmueble (variable original, con NA frecuentes)
estrato Numérica ordinal Estrato socioeconómico (3 a 6)
preciom Numérica continua Precio de venta, en millones de pesos (COP)
areaconst Numérica continua Área construida, en m²
parqueaderos Numérica discreta Número de parqueaderos (imputado por mediana tipo x estrato)
banios Numérica discreta Número de baños (0 recodificado a NA e imputado)
habitaciones Numérica discreta Número de habitaciones (0 recodificado a NA e imputado)
tipo Categórica Tipo de inmueble: Casa / Apartamento
barrio Categórica (texto) Barrio (normalizado: minúsculas, sin tildes)
longitud Numérica continua Coordenada geográfica de longitud
latitud Numérica continua Coordenada geográfica de latitud
piso_cat Categórica (derivada) Piso con categoría explícita ‘No reportado’
estrato_f Categórica ordinal (derivada) Estrato como factor ordenado
precio_m2 Numérica continua (derivada) Precio / área construida (millones COP por m²)
cluster Categórica (derivada) Segmento asignado por el análisis de conglomerados (HCPC), 6 niveles

16.2 Anexo B: Perfil completo de los seis segmentos (tabla ampliada)

kable(perfil, caption = tab_cap("Perfil promedio de cada segmento (tabla ampliada, para referencia)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 22. Perfil promedio de cada segmento (tabla ampliada, para referencia)
cluster n pct precio_medio_M area_media_m2 precio_m2_medio habitaciones_prom banios_prom parqueaderos_prom estrato_prom tipo_dominante zona_dominante
1 2187 26.3 166 72 2.37 2.8 1.9 1.0 3.6 Apartamento Zona Sur
2 2256 27.1 319 103 3.19 2.9 2.5 1.4 5.1 Apartamento Zona Sur
3 913 11.0 344 217 1.77 4.5 3.2 1.3 3.7 Casa Zona Sur
4 1824 21.9 688 228 3.31 3.7 4.1 2.2 5.6 Apartamento Zona Sur
5 475 5.7 502 340 1.61 7.6 5.1 1.6 3.9 Casa Zona Sur
6 664 8.0 1084 436 2.77 4.6 5.3 4.3 5.7 Casa Zona Sur

16.3 Anexo C: Tabla de contingencia completa zona × tipo × estrato

vivienda %>%
  count(zona, tipo, estrato_f) %>%
  pivot_wider(names_from = estrato_f, values_from = n, values_fill = 0) %>%
  kable(caption = tab_cap("Frecuencias cruzadas zona x tipo x estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
Tabla 23. Frecuencias cruzadas zona x tipo x estrato
zona tipo 3 4 5 6
Zona Centro Apartamento 14 7 3 0
Zona Centro Casa 91 7 1 1
Zona Norte Apartamento 337 246 498 117
Zona Norte Casa 235 161 271 55
Zona Oeste Apartamento 29 58 231 711
Zona Oeste Casa 25 26 59 59
Zona Oriente Apartamento 58 2 1 1
Zona Oriente Casa 282 6 1 0
Zona Sur Apartamento 201 1091 1033 462
Zona Sur Casa 181 525 652 581

16.4 Anexo D: Información de la sesión (reproducibilidad)

sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
## [5] LC_TIME=Spanish_Colombia.utf8    
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] paqueteMODELOS_0.1.0 summarytools_1.1.5   gridExtra_2.3.1     
##  [4] GGally_2.4.0         broom_1.0.13         boot_1.3-32         
##  [7] stringi_1.8.7        corrplot_0.95        FactoMineR_2.16     
## [10] skimr_2.2.2          kableExtra_1.4.0     knitr_1.51          
## [13] scales_1.4.0         ggplot2_4.0.2        tidyr_1.3.2         
## [16] dplyr_1.2.0         
## 
## loaded via a namespace (and not attached):
##  [1] tidyselect_1.2.1     viridisLite_0.4.3    farver_2.1.2        
##  [4] S7_0.2.1             fastmap_1.2.0        digest_0.6.39       
##  [7] estimability_2.0.0   timechange_0.4.0     lifecycle_1.0.5     
## [10] cluster_2.1.8.1      multcompView_0.1-12  magrittr_2.0.4      
## [13] compiler_4.5.2       rlang_1.1.7          sass_0.4.10         
## [16] tools_4.5.2          yaml_2.3.12          labeling_0.4.3      
## [19] htmlwidgets_1.6.4    scatterplot3d_0.3-45 plyr_1.8.9          
## [22] xml2_1.5.2           showtextdb_3.0       repr_1.1.7          
## [25] RColorBrewer_1.1-3   withr_3.0.3          purrr_1.2.1         
## [28] grid_4.5.2           sysfonts_0.8.9       xtable_1.8-8        
## [31] emmeans_2.0.4        MASS_7.3-65          flashClust_1.1-4    
## [34] cli_3.6.5            mvtnorm_1.4-2        rmarkdown_2.30      
## [37] generics_0.1.4       otel_0.2.0           rstudioapi_0.18.0   
## [40] reshape2_1.4.5       cachem_1.1.0         pander_0.6.6        
## [43] stringr_1.6.0        matrixStats_1.5.0    base64enc_0.1-6     
## [46] vctrs_0.7.1          Matrix_1.7-4         jsonlite_2.0.0      
## [49] rapportools_1.2      ggrepel_0.9.8        irlba_2.3.7         
## [52] systemfonts_1.3.1    magick_2.9.1         jquerylib_0.1.4     
## [55] glue_1.8.0           codetools_0.2-20     ggstats_0.13.0      
## [58] ggtext_0.1.2         DT_0.34.0            lubridate_1.9.5     
## [61] gtable_0.3.6         tibble_3.3.1         pillar_1.11.1       
## [64] htmltools_0.5.9      showtext_0.9-8       R6_2.6.1            
## [67] tcltk_4.5.2          textshaping_1.0.4    evaluate_1.0.5      
## [70] lattice_0.22-7       backports_1.5.1      leaps_3.2           
## [73] gridtext_0.1.6       bslib_0.10.0         Rcpp_1.1.1          
## [76] svglite_2.2.2        checkmate_2.3.4      xfun_0.56           
## [79] pkgconfig_2.0.3