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.
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.
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:
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.
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)
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 |
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.
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)
| 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
Hallazgos clave:
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)
| tipo | n | piso_NA | pct_NA |
|---|---|---|---|
| Apartamento | 5100 | 1381 | 27.1 |
| Casa | 3219 | 1254 | 39.0 |
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.
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)
| 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)
| 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.
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)
| 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
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)
| 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 |
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)
| 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.
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)
| 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 |
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).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.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)
| 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")
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%")
| 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 | ▅▆▁▇▆ |
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
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
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
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
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
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.
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
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.
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
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.
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.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)
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)
| 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
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.
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
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)
| 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:
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”.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
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)
| 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 |
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)
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)
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.
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
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)
| 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
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
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)
| 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")
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:
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).
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)
| 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)
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.
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)
| 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)
| 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
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.
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%")
| 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
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.
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)
| 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
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")
Las tres técnicas aplicadas son complementarias y convergen en una misma lectura del mercado:
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.
Con base en los hallazgos anteriores, se recomienda a la dirección de la empresa:
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.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.paqueteMODELOS (repositorio de
GitHub dgonxalex80/paqueteMODELOS), datos obtenidos
mediante web scraping de anuncios publicados en OLX.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)
| 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 |
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)
| 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 |
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)
| 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 |
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