Una empresa inmobiliaria que opera en la ciudad de Santiago de Cali requiere insumos cuantitativos para orientar sus decisiones de inversión, valoración de portafolio y segmentación comercial. La oferta de vivienda en una ciudad intermedia como Cali es heterogénea: coexisten zonas con estratos socioeconómicos muy distintos, tipologías de vivienda diversas (casas, apartamentos, apartaestudios, entre otras) y variaciones importantes en área construida, número de habitaciones, baños y parqueaderos.
Tomar decisiones basadas únicamente en el precio promedio, o en comparaciones aisladas de dos variables, es insuficiente cuando el problema es multivariable: el precio de una vivienda depende simultáneamente de su ubicación, su tamaño, sus comodidades y el estrato de la zona donde se encuentra. Por ello, este informe recurre a técnicas de análisis estadístico multivariado, que permiten resumir, clasificar y descubrir asociaciones en conjuntos de datos con muchas variables correlacionadas entre sí.
Objetivo general. Caracterizar la oferta inmobiliaria de Cali mediante técnicas de análisis multivariado, con el fin de apoyar decisiones estratégicas de la empresa inmobiliaria en materia de segmentación de mercado, priorización geográfica y política de precios.
Objetivos específicos:
Antes de aplicar las técnicas, se resumen sus elementos conceptuales clave, los cuales serán identificados explícitamente en cada sección de resultados:
Análisis de Componentes Principales (PCA)
Análisis de Conglomerados (Cluster)
Análisis de Correspondencias (Simple y Múltiple)
Se utiliza el conjunto de datos vivienda_faltantes,
disponible en el paquete paqueteMETODOS, que contiene
información de anuncios de vivienda en Cali con las siguientes
variables:
| Variable | Descripción | Tipo |
|---|---|---|
id |
Identificador único del inmueble | Nominal |
zona |
Zona geográfica de la ciudad (Norte, Sur, Oriente, etc.) | Categórica nominal |
piso |
Piso en el que se ubica el inmueble | Cuantitativa discreta |
estrato |
Estrato socioeconómico (1 a 6) | Categórica ordinal |
preciom |
Precio de venta (millones de pesos colombianos) | Cuantitativa continua |
areaconst |
Área construida (m²) | Cuantitativa continua |
parqueaderos |
Número de parqueaderos | Cuantitativa discreta |
banios |
Número de baños | Cuantitativa discreta |
habitaciones |
Número de habitaciones | Cuantitativa discreta |
tipo |
Tipo de inmueble (Casa, Apartamento, etc.) | Categórica nominal |
barrio |
Barrio o sector específico | Categórica nominal |
longitud |
Coordenada geográfica (longitud) | Cuantitativa continua |
latitud |
Coordenada geográfica (latitud) | Cuantitativa continua |
Tabla 1. Diccionario de variables (Anexo A).
options(repos = c(CRAN = "https://cloud.r-project.org"))
instalar_si_falta <- function(paquete) {
if (!requireNamespace(paquete, quietly = TRUE)) install.packages(paquete, dependencies = TRUE)
}
paquetes_cran <- c("tidyverse", "FactoMineR", "factoextra", "cluster", "ca",
"corrplot", "knitr", "DT", "scales", "viridis", "devtools")
invisible(lapply(paquetes_cran, instalar_si_falta))
if (!requireNamespace("paqueteMETODOS", quietly = TRUE)) {
devtools::install_github("dgonxalex80/paqueteMETODOS")
}
library(tidyverse)
library(FactoMineR)
library(factoextra)
library(cluster)
library(ca)
library(corrplot)
library(knitr)
library(DT)
library(scales)
library(viridis)
set.seed(2026)data("vivienda_faltantes", package = "paqueteMETODOS")
vivienda <- vivienda_faltantes %>%
rename(parqueaderos = parquea, habitaciones = habitac)
cat("Dimensiones del conjunto de datos original:", dim(vivienda)[1], "filas x", dim(vivienda)[2], "columnas\n")## Dimensiones del conjunto de datos original: 8330 filas x 13 columnas
## spc_tbl_ [8,330 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
## $ id : num [1:8330] 8312 8311 8307 8296 8297 ...
## $ zona : chr [1:8330] "Zona Oeste" "Zona Oeste" "Zona Oeste" "Zona Sur" ...
## $ piso : num [1:8330] 4 1 NA 2 NA NA 2 NA NA 2 ...
## $ estrato : num [1:8330] 6 6 5 3 5 5 6 5 5 5 ...
## $ preciom : num [1:8330] 1300 480 1200 220 330 1350 305 480 275 285 ...
## $ areaconst : num [1:8330] 318 300 800 150 112 390 125 280 74 120 ...
## $ parqueaderos: num [1:8330] 2 1 4 1 2 8 2 4 1 2 ...
## $ banios : num [1:8330] 4 4 7 2 4 10 3 4 2 4 ...
## $ habitaciones: num [1:8330] 2 4 5 4 3 10 3 4 3 3 ...
## $ tipo : chr [1:8330] "Apartamento" "Casa" "Casa" "Casa" ...
## $ barrio : chr [1:8330] "arboleda" "normandía" "miraflores" "el guabal" ...
## $ longitud : num [1:8330] -76576 -76571 -76568 -76565 -76565 ...
## $ latitud : num [1:8330] 3454 3454 3455 3417 3408 ...
## - attr(*, "spec")=
## .. cols(
## .. id = col_double(),
## .. zona = col_character(),
## .. piso = col_double(),
## .. estrato = col_double(),
## .. preciom = col_double(),
## .. areaconst = col_double(),
## .. parquea = col_double(),
## .. banios = col_double(),
## .. habitac = col_double(),
## .. tipo = col_character(),
## .. barrio = col_character(),
## .. longitud = col_double(),
## .. latitud = col_double()
## .. )
## - attr(*, "problems")=<externalptr>
El nombre del conjunto de datos (vivienda_faltantes)
indica explícitamente la presencia de datos ausentes, por lo que este
diagnóstico es un paso obligatorio de la metodología.
miss_summary <- 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(miss_summary, col.names = c("Variable", "N° de NA", "% de NA"),
caption = "Tabla 3. Resumen de valores faltantes por variable")| Variable | N° de NA | % de NA |
|---|---|---|
| piso | 2641 | 31.70 |
| parqueaderos | 1606 | 19.28 |
| 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(miss_summary, aes(x = reorder(variable, pct_na), y = pct_na)) +
geom_col(fill = "#2C3E50") +
coord_flip() +
labs(title = "Porcentaje de valores faltantes por variable",
subtitle = "Diagnóstico previo a la imputación",
x = NULL, y = "% de valores faltantes") +
theme_minimal(base_size = 12)Además de los valores faltantes (NA), la exploración
inicial de los datos crudos reveló cuatro problemas adicionales
de calidad de datos que deben corregirse antes de cualquier
técnica multivariada:
tipo: la
misma categoría aparece escrita de formas distintas
("Apartamento", "APARTAMENTO",
"apto", "casa", "Casa",
"CASA"), lo que haría que el software las tratara como
grupos distintos.preciom y
areaconst (precio o área construida igual a 0),
incompatibles con una vivienda real en venta.longitud/latitud: una parte de los
registros trae la coordenada sin punto decimal (p. ej.
-76576 en vez de -76.576), lo cual es
geográficamente imposible (una longitud válida está entre -180 y 180, y
una latitud entre -90 y 90) y distorsionaría por completo el mapa.Adicionalmente, dado que las técnicas multivariadas (PCA, K-medias, CA/MCA) no admiten valores faltantes, se aplica un procedimiento de imputación simple y transparente: variables cuantitativas se imputan con la mediana (robusta ante valores atípicos, frecuentes en precios de vivienda) y variables categóricas con la moda (categoría más frecuente). Los registros sin coordenadas geográficas válidas se excluyen únicamente para los análisis que requieren geolocalización.
n_antes <- nrow(vivienda)
vivienda_clean <- vivienda %>% distinct()
cat("Filas exactamente duplicadas eliminadas:", n_antes - nrow(vivienda_clean), "\n")## Filas exactamente duplicadas eliminadas: 9
##
## Categorias de 'tipo' ANTES de normalizar:
##
## Apartamento APARTAMENTO apto casa Casa CASA
## 5025 61 13 14 3194 12
vivienda_clean <- vivienda_clean %>%
mutate(
tipo = str_trim(str_to_lower(tipo)),
tipo = case_when(
tipo %in% c("apartamento", "apto") ~ "Apartamento",
tipo == "casa" ~ "Casa",
TRUE ~ str_to_title(tipo)
)
)
cat("\nCategorias de 'tipo' DESPUES de normalizar:\n")##
## Categorias de 'tipo' DESPUES de normalizar:
##
## Apartamento Casa
## 5099 3220
cat("\nValores de 'preciom' <= 0 recodificados a NA:", sum(vivienda_clean$preciom <= 0, na.rm = TRUE), "\n")##
## Valores de 'preciom' <= 0 recodificados a NA: 0
cat("Valores de 'areaconst' <= 0 recodificados a NA:", sum(vivienda_clean$areaconst <= 0, na.rm = TRUE), "\n")## Valores de 'areaconst' <= 0 recodificados a NA: 0
vivienda_clean <- vivienda_clean %>%
mutate(
preciom = if_else(preciom <= 0, NA_real_, preciom),
areaconst = if_else(areaconst <= 0, NA_real_, areaconst)
)
cat("\nCoordenadas de longitud corregidas (escala x1000):", sum(abs(vivienda_clean$longitud) > 180, na.rm = TRUE), "\n")##
## Coordenadas de longitud corregidas (escala x1000): 2371
cat("Coordenadas de latitud corregidas (escala x1000):", sum(abs(vivienda_clean$latitud) > 90, na.rm = TRUE), "\n")## Coordenadas de latitud corregidas (escala x1000): 2357
vivienda_clean <- vivienda_clean %>%
mutate(
longitud = ifelse(abs(longitud) > 180, longitud / 1000, longitud),
latitud = ifelse(abs(latitud) > 90, latitud / 1000, latitud)
)
vivienda_clean <- vivienda_clean %>%
mutate(
zona = as.factor(zona),
tipo = as.factor(tipo),
barrio = as.factor(barrio),
estrato = factor(estrato, ordered = TRUE)
)
impute_mediana <- function(x) {
x[is.na(x)] <- median(x, na.rm = TRUE)
x
}
impute_moda <- function(x) {
if (any(is.na(x))) {
moda_val <- names(sort(table(x), decreasing = TRUE))[1]
x[is.na(x)] <- moda_val
}
x
}
num_vars <- c("preciom", "areaconst", "parqueaderos", "banios", "habitaciones", "piso")
cat_vars <- c("zona", "tipo", "barrio", "estrato")
vivienda_clean <- vivienda_clean %>%
mutate(across(all_of(num_vars), impute_mediana)) %>%
mutate(across(all_of(cat_vars), impute_moda))
n_sin_geo <- sum(is.na(vivienda_clean$longitud) | is.na(vivienda_clean$latitud))
vivienda_clean <- vivienda_clean %>% filter(!is.na(longitud), !is.na(latitud))
cat("\nRegistros excluidos por falta de coordenadas geograficas:", n_sin_geo,
paste0("(", round(100 * n_sin_geo / nrow(vivienda), 2), "% del total)"), "\n")##
## Registros excluidos por falta de coordenadas geograficas: 2 (0.02% del total)
## Registros finales para el analisis: 8319
## [1] 0
desc_stats <- vivienda_clean %>%
select(all_of(num_vars)) %>%
summarise(across(everything(),
list(min = ~min(.), media = ~mean(.), mediana = ~median(.),
max = ~max(.), sd = ~sd(.)),
.names = "{.col}__{.fn}")) %>%
pivot_longer(everything(), names_to = c("variable", "estadistico"), names_sep = "__") %>%
pivot_wider(names_from = estadistico, values_from = value)
kable(desc_stats, digits = 2, caption = "Tabla 4. Estadísticos descriptivos de variables cuantitativas")| variable | min | media | mediana | max | sd |
|---|---|---|---|---|---|
| preciom | 58 | 433.90 | 330 | 1999 | 328.67 |
| areaconst | 30 | 174.93 | 123 | 1745 | 142.96 |
| parqueaderos | 1 | 1.87 | 2 | 10 | 1.01 |
| banios | 0 | 3.11 | 3 | 10 | 1.43 |
| habitaciones | 0 | 3.61 | 3 | 10 | 1.46 |
| piso | 1 | 3.53 | 3 | 12 | 2.19 |
p1 <- ggplot(vivienda_clean, aes(x = fct_infreq(zona), fill = zona)) +
geom_bar() + coord_flip() + scale_fill_viridis_d(guide = "none") +
labs(title = "Distribución de la oferta por zona", x = NULL, y = "N° de inmuebles") +
theme_minimal(base_size = 12)
p1p2 <- ggplot(vivienda_clean, aes(x = fct_infreq(tipo), fill = tipo)) +
geom_bar() + coord_flip() + scale_fill_viridis_d(guide = "none") +
labs(title = "Distribución de la oferta por tipo de inmueble", x = NULL, y = "N° de inmuebles") +
theme_minimal(base_size = 12)
p2p3 <- ggplot(vivienda_clean, aes(x = estrato, fill = estrato)) +
geom_bar() + scale_fill_viridis_d(guide = "none") +
labs(title = "Distribución de la oferta por estrato socioeconómico", x = "Estrato", y = "N° de inmuebles") +
theme_minimal(base_size = 12)
p3ggplot(vivienda_clean, aes(x = reorder(zona, preciom, median), y = preciom, fill = zona)) +
geom_boxplot(outlier.alpha = 0.4) +
scale_fill_viridis_d(guide = "none") +
scale_y_continuous(labels = label_comma()) +
coord_flip() +
labs(title = "Precio de venta según zona de la ciudad",
subtitle = "Boxplot: mediana, rango intercuartílico y valores atípicos",
x = NULL, y = "Precio (millones COP)") +
theme_minimal(base_size = 12)ggplot(vivienda_clean, aes(x = reorder(tipo, preciom, median), y = preciom, fill = tipo)) +
geom_boxplot(outlier.alpha = 0.4) +
scale_fill_viridis_d(guide = "none") +
scale_y_continuous(labels = label_comma()) +
coord_flip() +
labs(title = "Precio de venta según tipo de inmueble",
x = NULL, y = "Precio (millones COP)") +
theme_minimal(base_size = 12)ggplot(vivienda_clean, aes(x = estrato, y = preciom, fill = estrato)) +
geom_boxplot(outlier.alpha = 0.4) +
scale_fill_viridis_d(guide = "none") +
scale_y_continuous(labels = label_comma()) +
labs(title = "Precio de venta según estrato socioeconómico",
x = "Estrato", y = "Precio (millones COP)") +
theme_minimal(base_size = 12)cor_matrix <- cor(vivienda_clean %>% select(all_of(num_vars)))
corrplot(cor_matrix, method = "color", type = "upper", addCoef.col = "black",
tl.col = "black", tl.srt = 45, number.cex = 0.8,
title = "Matriz de correlación entre variables cuantitativas",
mar = c(0, 0, 2, 0))Lectura preliminar: se observa que
preciomestá fuertemente asociado conareaconst,habitaciones,baniosyparqueaderos, lo cual sugiere que estas variables comparten un eje común de variación relacionado con el tamaño/categoría del inmueble, insumo clave para el PCA que sigue.
Se define como variables activas el conjunto de
variables cuantitativas que describen las características físicas y de
precio del inmueble: piso, preciom,
areaconst, parqueaderos, banios,
habitaciones. Se incluyen como variables
suplementarias estrato (cuantitativa, ordinal
convertida a numérica), longitud, latitud
(cuantitativas) y zona, tipo (categóricas),
las cuales se proyectan en el mapa factorial para apoyar la
interpretación sin influir en la construcción de los componentes.
pca_df <- vivienda_clean %>%
transmute(
piso, preciom, areaconst, parqueaderos, banios, habitaciones,
estrato_num = as.numeric(as.character(estrato)),
longitud, latitud,
zona, tipo
) %>%
as.data.frame()
res.pca <- PCA(pca_df,
quanti.sup = which(names(pca_df) %in% c("estrato_num", "longitud", "latitud")),
quali.sup = which(names(pca_df) %in% c("zona", "tipo")),
graph = FALSE)eig.val <- get_eigenvalue(res.pca)
kable(eig.val, digits = 2, caption = "Tabla 5. Valores propios y varianza explicada por componente")| eigenvalue | variance.percent | cumulative.variance.percent | |
|---|---|---|---|
| Dim.1 | 3.16 | 52.60 | 52.60 |
| Dim.2 | 1.06 | 17.64 | 70.24 |
| Dim.3 | 0.82 | 13.63 | 83.87 |
| Dim.4 | 0.44 | 7.34 | 91.21 |
| Dim.5 | 0.34 | 5.59 | 96.79 |
fviz_eig(res.pca, addlabels = TRUE, barfill = "#2C3E50", barcolor = "#2C3E50",
linecolor = "#E67E22") +
labs(title = "Gráfico de sedimentación (Scree plot)",
subtitle = "Criterio de Kaiser: retener componentes con valor propio > 1",
x = "Componente", y = "% de varianza explicada")Interpretación: siguiendo el criterio de Kaiser (valor propio > 1) y el porcentaje de varianza acumulada, se retienen las dos primeras dimensiones, que concentran la mayor parte de la información original y se emplean para las visualizaciones factoriales.
var.pca <- get_pca_var(res.pca)
kable(var.pca$contrib[, 1:2], digits = 2, caption = "Tabla 6. Contribución (%) de cada variable a las Dim 1 y Dim 2")| Dim.1 | Dim.2 | |
|---|---|---|
| piso | 1.22 | 69.80 |
| preciom | 21.85 | 9.40 |
| areaconst | 23.89 | 0.08 |
| parqueaderos | 16.40 | 5.49 |
| banios | 23.57 | 0.03 |
| habitaciones | 13.07 | 15.20 |
kable(var.pca$cos2[, 1:2], digits = 2, caption = "Tabla 7. Calidad de representación (cos²) de cada variable")| Dim.1 | Dim.2 | |
|---|---|---|
| piso | 0.04 | 0.74 |
| preciom | 0.69 | 0.10 |
| areaconst | 0.75 | 0.00 |
| parqueaderos | 0.52 | 0.06 |
| banios | 0.74 | 0.00 |
| habitaciones | 0.41 | 0.16 |
fviz_pca_var(res.pca, col.var = "contrib",
gradient.cols = c("#2C3E50", "#F1C40F", "#E74C3C"),
repel = TRUE) +
labs(title = "Círculo de correlaciones: variables activas",
subtitle = "Color según contribución a los dos primeros componentes")Interpretación: las variables
areaconst,habitaciones,banios,parqueaderosypreciomcargan de forma positiva y con alta contribución sobre la Dimensión 1, la cual puede interpretarse como un eje de tamaño/valor del inmueble: a la derecha del plano se ubican viviendas grandes y costosas; a la izquierda, viviendas pequeñas y económicas. La variablepisoaporta de forma más relevante a la Dimensión 2, diferenciando inmuebles en altura (apartamentos en pisos altos) de inmuebles a nivel de calle (casas).
fviz_pca_var(res.pca, col.var = "black", col.quanti.sup = "#E74C3C",
repel = TRUE) +
labs(title = "Variables activas y suplementarias cuantitativas",
subtitle = "Estrato, longitud y latitud proyectadas como suplementarias")El estrato socioeconómico (suplementario) se proyecta con una correlación positiva marcada con la Dimensión 1, lo que confirma que el eje de “tamaño/valor” está fuertemente ligado al nivel socioeconómico de la zona donde se ubica la vivienda.
fviz_pca_ind(res.pca, geom = "point", habillage = pca_df$zona,
addEllipses = TRUE, ellipse.level = 0.68, palette = "viridis") +
labs(title = "Individuos en el plano factorial coloreados por zona",
subtitle = "Elipses de confianza al 68% por grupo")fviz_pca_ind(res.pca, geom = "point", habillage = pca_df$tipo,
addEllipses = TRUE, ellipse.level = 0.68, palette = "viridis") +
labs(title = "Individuos en el plano factorial coloreados por tipo de inmueble")fviz_pca_biplot(res.pca, geom.ind = "point", col.ind = pca_df$estrato_num,
gradient.cols = c("#2C3E50", "#F1C40F", "#E74C3C"),
col.var = "black", repel = TRUE, alpha.ind = 0.6) +
labs(title = "Biplot PCA: individuos coloreados por estrato",
subtitle = "Proyección conjunta de inmuebles y variables",
color = "Estrato")La función
dimdescconfirma, mediante correlaciones significativas (p < 0.05), que la Dimensión 1 está construida principalmente porareaconst,preciom,habitaciones,baniosyparqueaderos, validando la interpretación de un eje de tamaño/valor del inmueble.
Dado que las variables tienen escalas muy distintas (precio en millones vs. número de habitaciones), es indispensable estandarizar antes de calcular distancias euclidianas.
set.seed(2026)
wss <- sapply(1:10, function(k) kmeans(cluster_scaled, centers = k, nstart = 25)$tot.withinss)
ggplot(data.frame(k = 1:10, wss = wss), aes(x = k, y = wss)) +
geom_line(color = "#2C3E50") +
geom_point(color = "#2C3E50", size = 2) +
geom_vline(xintercept = 4, linetype = "dashed", color = "#E74C3C") +
scale_x_continuous(breaks = 1:10) +
labs(title = "Método del codo",
subtitle = "Inercia intra-cluster (WSS) según número de grupos k",
x = "Número de grupos (k)", y = "WSS (inercia intra-cluster)") +
theme_minimal(base_size = 12)El coeficiente de silueta se calcula sobre una muestra aleatoria de 2,000 observaciones (en vez de las 8,330 totales) por eficiencia computacional, dado que su cálculo exacto requiere una matriz de distancias completa de tamaño n².
set.seed(2026)
idx_sil <- sample(nrow(cluster_scaled), min(2000, nrow(cluster_scaled)))
fviz_nbclust(cluster_scaled[idx_sil, ], kmeans, method = "silhouette", k.max = 10) +
labs(title = "Método de la silueta promedio",
subtitle = "k que maximiza el ancho de silueta promedio (muestra de 2,000 obs.)")Decisión: ambos criterios convergen en un número de grupos razonable entre k = 4, valor que además resulta interpretable desde la perspectiva del negocio (segmentos de mercado diferenciables). Se adopta k = 4 para el modelo final.
set.seed(2026)
k_optimo <- 4
km_res <- kmeans(cluster_scaled, centers = k_optimo, nstart = 25)
cat("Tamaño de cada conglomerado:\n")## Tamaño de cada conglomerado:
## [1] 975 2240 4240 864
##
## Inercia intra-cluster (withinss): 22297.13
## Inercia entre-clusters (betweenss): 27610.87
cat("Razón betweenss/totss (calidad de la partición):",
round(100 * km_res$betweenss / km_res$totss, 1), "%\n")## Razón betweenss/totss (calidad de la partición): 55.3 %
fviz_cluster(km_res, data = cluster_scaled, geom = "point",
ellipse.type = "convex", palette = "viridis", repel = FALSE) +
labs(title = "Segmentación de la oferta inmobiliaria (K-medias, k = 4)",
subtitle = "Proyección sobre los dos primeros componentes principales")set.seed(2026)
idx_sil_final <- sample(nrow(cluster_scaled), min(2000, nrow(cluster_scaled)))
sil <- silhouette(km_res$cluster[idx_sil_final], dist(cluster_scaled[idx_sil_final, ]))
fviz_silhouette(sil) +
labs(title = "Perfil de silueta del modelo final (k = 4)",
subtitle = "Muestra aleatoria de 2,000 observaciones")## cluster size ave.sil.width
## 1 1 226 0.31
## 2 2 536 0.16
## 3 3 1031 0.44
## 4 4 207 0.13
El ancho de silueta promedio confirma una separación adecuada entre segmentos; valores individuales negativos (si existen) señalan inmuebles “frontera” con características mixtas entre dos segmentos, frecuentes en mercados inmobiliarios reales.
vivienda_clean$cluster <- factor(km_res$cluster)
perfil_clusters <- vivienda_clean %>%
group_by(cluster) %>%
summarise(across(all_of(num_vars), mean), n = n(), .groups = "drop")
kable(perfil_clusters, digits = 1, caption = "Tabla 8. Perfil promedio de cada conglomerado")| cluster | preciom | areaconst | parqueaderos | banios | habitaciones | piso | n |
|---|---|---|---|---|---|---|---|
| 1 | 389.2 | 113.1 | 1.6 | 2.8 | 2.9 | 8.4 | 975 |
| 2 | 521.1 | 248.4 | 1.9 | 4.2 | 4.9 | 2.7 | 2240 |
| 3 | 255.1 | 98.4 | 1.5 | 2.2 | 2.9 | 3.0 | 4240 |
| 4 | 1136.2 | 429.7 | 3.9 | 5.2 | 4.5 | 3.0 | 864 |
tabla_tipo <- vivienda_clean %>%
count(cluster, tipo) %>%
group_by(cluster) %>%
mutate(pct = round(100 * n / sum(n), 1))
ggplot(tabla_tipo, aes(x = cluster, y = pct, fill = tipo)) +
geom_col(position = "stack") +
scale_fill_viridis_d() +
labs(title = "Composición de cada conglomerado según tipo de inmueble",
x = "Conglomerado", y = "% dentro del conglomerado", fill = "Tipo") +
theme_minimal(base_size = 12)tabla_estrato <- vivienda_clean %>%
count(cluster, estrato) %>%
group_by(cluster) %>%
mutate(pct = round(100 * n / sum(n), 1))
ggplot(tabla_estrato, aes(x = cluster, y = pct, fill = estrato)) +
geom_col(position = "stack") +
scale_fill_viridis_d() +
labs(title = "Composición de cada conglomerado según estrato socioeconómico",
x = "Conglomerado", y = "% dentro del conglomerado", fill = "Estrato") +
theme_minimal(base_size = 12)perfil_z <- vivienda_clean %>%
select(cluster, all_of(num_vars)) %>%
mutate(across(all_of(num_vars), scale)) %>%
group_by(cluster) %>%
summarise(across(everything(), mean), .groups = "drop") %>%
pivot_longer(-cluster, names_to = "variable", values_to = "z_score")
ggplot(perfil_z, aes(x = variable, y = z_score, fill = z_score > 0)) +
geom_col() +
coord_flip() +
facet_wrap(~cluster, labeller = label_both) +
scale_fill_manual(values = c("TRUE" = "#2C3E50", "FALSE" = "#E74C3C"), guide = "none") +
labs(title = "Huella (fingerprint) estandarizada de cada conglomerado",
subtitle = "Valores por encima/debajo del promedio general del mercado",
x = NULL, y = "Puntaje estandarizado (z)") +
theme_minimal(base_size = 11)Interpretación de negocio de los segmentos (a validar con los promedios exactos de la Tabla 8):
- Segmento Premium / Alto valor: áreas construidas grandes, mayor número de habitaciones, baños y parqueaderos, precios elevados; concentra estratos altos y predominio de casas o apartamentos de gran formato.
- Segmento Familiar Medio: viviendas de tamaño intermedio, con parqueadero y 2-3 habitaciones; corresponde al grueso de la oferta en estratos 3-4.
- Segmento Compacto / Económico: apartamentos pequeños, pocos parqueaderos, precios bajos; asociado a estratos 1-2 y a compradores de primera vivienda o inversionistas de bajo capital.
- Segmento de Oportunidad / Atípico: combinaciones inusuales (p. ej. alto estrato con área reducida, o precio alto relativo a sus características), útiles para identificar sobrevaloración o subvaloración puntual.
Se estudia la asociación entre zona geográfica y estrato socioeconómico, dos variables con suficientes categorías cada una (5 y 4, respectivamente) para producir un mapa de correspondencias interpretable en dos dimensiones, información clave para orientar el portafolio de oferta según el nivel socioeconómico predominante en cada zona de la ciudad.
tabla_ca <- table(vivienda_clean$zona, vivienda_clean$estrato)
kable(tabla_ca, caption = "Tabla 9. Tabla de contingencia: Zona x Estrato socioeconómico")| 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 |
##
## Pearson's Chi-squared test
##
## data: tabla_ca
## X-squared = 3830.4, df = 12, p-value < 2.2e-16
Con un valor-p < 0.001, se rechaza la hipótesis de independencia entre zona y estrato socioeconómico, lo cual justifica estadísticamente la interpretación del mapa de correspondencias.
res.ca <- CA(tabla_ca, graph = FALSE)
kable(get_eigenvalue(res.ca), digits = 2,
caption = "Tabla 10. Valores propios e inercia explicada del CA")| eigenvalue | variance.percent | cumulative.variance.percent | |
|---|---|---|---|
| Dim.1 | 0.32 | 69.97 | 69.97 |
| Dim.2 | 0.13 | 27.68 | 97.65 |
| Dim.3 | 0.01 | 2.35 | 100.00 |
fviz_ca_biplot(res.ca, repel = TRUE) +
labs(title = "Mapa de Correspondencias: Zona x Estrato socioeconómico",
subtitle = "Proximidad = asociación entre categorías")Interpretación: las categorías de zona y estrato que se ubican cerca entre sí en el mapa presentan asociación positiva (co-ocurren con mayor frecuencia de la esperada bajo independencia). Esto permite identificar, por ejemplo, qué zonas concentran los estratos más altos (mayor valor de mercado) y cuáles concentran los estratos más bajos, en línea con lo observado en el PCA y el clustering.
Para capturar simultáneamente varias variables categóricas, se
construye una base con zona, tipo,
estrato y versiones categorizadas de
habitaciones, banios y
parqueaderos. Se incluyen preciom y
areaconst como variables cuantitativas suplementarias.
mca_df <- vivienda_clean %>%
transmute(
zona, tipo, estrato,
cat_habitaciones = cut(habitaciones, breaks = c(-Inf, 1, 2, 3, Inf),
labels = c("1 o menos", "2", "3", "4 o más")),
cat_banios = cut(banios, breaks = c(-Inf, 1, 2, Inf),
labels = c("1", "2", "3 o más")),
cat_parqueaderos = cut(parqueaderos, breaks = c(-Inf, 1, 2, Inf),
labels = c("1", "2", "3 o más")),
preciom, areaconst
) %>%
as.data.frame()
res.mca <- MCA(mca_df,
quanti.sup = which(names(mca_df) %in% c("preciom", "areaconst")),
graph = FALSE)kable(head(get_eigenvalue(res.mca), 8), digits = 2,
caption = "Tabla 11. Valores propios del MCA (primeras dimensiones)")| eigenvalue | variance.percent | cumulative.variance.percent | |
|---|---|---|---|
| Dim.1 | 0.41 | 16.32 | 16.32 |
| Dim.2 | 0.32 | 12.63 | 28.95 |
| Dim.3 | 0.26 | 10.31 | 39.27 |
| Dim.4 | 0.19 | 7.76 | 47.02 |
| Dim.5 | 0.18 | 7.27 | 54.29 |
fviz_screeplot(res.mca, addlabels = TRUE, barfill = "#2C3E50", barcolor = "#2C3E50") +
labs(title = "Scree plot del MCA", x = "Dimensión", y = "% de varianza explicada")fviz_mca_var(res.mca, repel = TRUE, col.var = "contrib",
gradient.cols = c("#2C3E50", "#F1C40F", "#E74C3C")) +
labs(title = "Nube de categorías (MCA)",
subtitle = "Color según contribución a las dos primeras dimensiones")fviz_mca_ind(res.mca, geom = "point", habillage = vivienda_clean$cluster,
addEllipses = TRUE, palette = "viridis") +
labs(title = "Individuos en el plano del MCA coloreados por conglomerado",
subtitle = "Validación cruzada entre la segmentación K-medias y las categorías")Interpretación: la Dimensión 1 del MCA vuelve a alinear estrato alto, zonas de mayor plusvalía, tipologías de mayor tamaño y más habitaciones/baños en un mismo cuadrante, mientras que estrato bajo, tipologías compactas y menor número de comodidades se agrupan en el cuadrante opuesto. El precio y el área construida (suplementarias) se proyectan coherentemente sobre este mismo eje, triangulando el hallazgo obtenido en el PCA y en el clustering: existe un eje dominante de “tamaño/valor/estrato” que estructura el mercado inmobiliario de Cali.
ggplot(vivienda_clean, aes(x = longitud, y = latitud, color = cluster)) +
geom_point(alpha = 0.7, size = 1.8) +
scale_color_viridis_d() +
coord_fixed(1.3) +
labs(title = "Distribución geográfica de la oferta inmobiliaria en Cali",
subtitle = "Coloreado por conglomerado (segmento de mercado)",
x = "Longitud", y = "Latitud", color = "Conglomerado") +
theme_minimal(base_size = 12)ggplot(vivienda_clean, aes(x = longitud, y = latitud, color = preciom)) +
geom_point(alpha = 0.7, size = 1.8) +
scale_color_viridis_c(labels = label_comma()) +
coord_fixed(1.3) +
labs(title = "Mapa de calor geográfico del precio de vivienda",
x = "Longitud", y = "Latitud", color = "Precio\n(millones COP)") +
theme_minimal(base_size = 12)ggplot(vivienda_clean, aes(x = areaconst, y = preciom, color = cluster)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", se = FALSE, linewidth = 0.8) +
scale_color_viridis_d() +
scale_y_continuous(labels = label_comma()) +
labs(title = "Relación entre área construida y precio, por conglomerado",
x = "Área construida (m²)", y = "Precio (millones COP)", color = "Conglomerado") +
theme_minimal(base_size = 12)tabla_heat <- vivienda_clean %>%
group_by(zona, cluster) %>%
summarise(precio_medio = mean(preciom), .groups = "drop")
ggplot(tabla_heat, aes(x = cluster, y = zona, fill = precio_medio)) +
geom_tile(color = "white") +
scale_fill_viridis_c(labels = label_comma()) +
labs(title = "Precio promedio por zona y conglomerado",
x = "Conglomerado", y = "Zona", fill = "Precio medio\n(millones COP)") +
theme_minimal(base_size = 12)El Análisis de Componentes Principales
identificó un eje dominante (Dimensión 1) que resume el tamaño y
valor del inmueble (área construida, habitaciones, baños,
parqueaderos y precio se mueven juntos), y una segunda dimensión
asociada a la altura/tipo de edificación (piso). Estas dos
dimensiones concentran la mayor parte de la variabilidad del mercado y
permiten reducir 6 variables cuantitativas a un mapa interpretable de 2
ejes.
El Análisis de Conglomerados, validado mediante el método del codo y el coeficiente de silueta, reveló 4 segmentos naturales de mercado con perfiles diferenciados en tamaño, precio, comodidades y estrato asociado, lo que constituye una segmentación operativa directamente utilizable por el equipo comercial.
El Análisis de Correspondencias Simple confirmó, con respaldo de la prueba Chi-cuadrado, una asociación estadísticamente significativa entre zona geográfica y estrato socioeconómico, evidenciando que los distintos sectores de la ciudad concentran de forma desigual a la población por nivel socioeconómico.
El Análisis de Correspondencias Múltiples trianguló los hallazgos anteriores: zona, estrato, tipo de inmueble y número de comodidades se organizan alrededor del mismo eje de “tamaño/valor” detectado en el PCA y el clustering, lo que da robustez metodológica a las conclusiones (tres técnicas distintas apuntan al mismo patrón estructural del mercado).
La visualización geográfica evidenció que los segmentos de mayor valor no se distribuyen de manera uniforme en la ciudad, sino que se concentran espacialmente, lo cual tiene implicaciones directas para la estrategia de expansión de la empresa.
Priorizar la prospección comercial en las zonas donde se concentra el segmento Premium identificado en el clustering y el mapa geográfico, dado su mayor valor promedio por unidad y su asociación con estratos altos.
Diseñar portafolios diferenciados por segmento: el segmento Familiar Medio, al ser el de mayor tamaño muestral, debería concentrar el mayor esfuerzo de intermediación estándar, mientras que el segmento Compacto/Económico exige estrategias de rotación rápida y financiamiento accesible.
Usar el eje “tamaño/valor” del PCA como base de un índice de valoración automatizado, que permita detectar inmuebles sub o sobrevalorados comparando su precio observado contra el esperado según su posición en el plano factorial (útil para identificar oportunidades de arbitraje o negociación).
Revisar activamente los inmuebles “frontera” detectados por silueta baja o negativa en el clustering: suelen ser casos con combinaciones atípicas de atributos, ya sea oportunidades de inversión (subvalorados) o riesgos de sobreprecio.
Incorporar la variable zona/barrio explícitamente en la estrategia de marketing digital, dado que el MCA confirmó que zona y estrato están fuertemente correlacionados con el tipo de inmueble preferido, permitiendo campañas geo-segmentadas más eficientes.
Establecer un proceso periódico de actualización de estos modelos (trimestral o semestral) para capturar cambios en el mercado inmobiliario de Cali, dado que la estructura de precios y segmentos puede variar por efectos macroeconómicos o de oferta/demanda local.
Ver Tabla 1 en la sección de Metodología.
kable(miss_summary, col.names = c("Variable", "N° de NA", "% de NA"),
caption = "Anexo B.1 Resumen de valores faltantes original")| Variable | N° de NA | % de NA |
|---|---|---|
| piso | 2641 | 31.70 |
| parqueaderos | 1606 | 19.28 |
| 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 |
| 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 |
## R version 4.5.1 (2025-06-13 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_Bolivia.utf8 LC_CTYPE=Spanish_Bolivia.utf8
## [3] LC_MONETARY=Spanish_Bolivia.utf8 LC_NUMERIC=C
## [5] LC_TIME=Spanish_Bolivia.utf8
##
## time zone: America/La_Paz
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] viridis_0.6.5 viridisLite_0.4.2 scales_1.4.0 DT_0.34.0
## [5] knitr_1.51 corrplot_0.95 ca_0.71.1 cluster_2.1.8.3
## [9] factoextra_2.2.0 FactoMineR_2.16 lubridate_1.9.4 forcats_1.0.1
## [13] stringr_1.5.2 dplyr_1.2.1 purrr_1.1.0 readr_2.1.6
## [17] tidyr_1.3.2 tibble_3.3.0 ggplot2_4.0.1 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] RColorBrewer_1.1-3 paqueteMETODOS_0.1.0 rstudioapi_0.18.0
## [4] jsonlite_2.0.0 magrittr_2.0.4 TH.data_1.1-5
## [7] estimability_2.0.0 magick_2.9.1 farver_2.1.2
## [10] rmarkdown_2.30 fs_2.1.0 ragg_1.5.0
## [13] vctrs_0.7.3 memoise_2.0.1 askpass_1.2.1
## [16] base64enc_0.1-3 rstatix_1.1.0 htmltools_0.5.9
## [19] usethis_3.2.1 broom_1.0.10 Formula_1.2-5
## [22] sass_0.4.10 bslib_0.10.0 htmlwidgets_1.6.4
## [25] plyr_1.8.9 sandwich_3.1-1 emmeans_2.0.4
## [28] zoo_1.8-15 cachem_1.1.0 uuid_1.2-2
## [31] lifecycle_1.0.5 pkgconfig_2.0.3 Matrix_1.7-3
## [34] R6_2.6.1 fastmap_1.2.0 digest_0.6.39
## [37] showtext_0.9-8 patchwork_1.3.2 irlba_2.3.7
## [40] pkgload_1.5.3 crosstalk_1.2.2 textshaping_1.0.4
## [43] ggpubr_1.0.0 labeling_0.4.3 timechange_0.3.0
## [46] mgcv_1.9-3 abind_1.4-8 compiler_4.5.1
## [49] fontquiver_0.2.1 withr_3.0.2 pander_0.6.6
## [52] S7_0.2.0 backports_1.5.0 carData_3.0-5
## [55] psych_2.5.6 pkgbuild_1.4.8 ggsignif_0.6.4
## [58] MASS_7.3-65 openssl_2.4.2 sessioninfo_1.2.4
## [61] scatterplot3d_0.3-45 flashClust_1.1-4 tools_4.5.1
## [64] lmtest_0.9-40 otel_0.2.0 zip_3.0.2
## [67] glue_1.8.0 nlme_3.1-168 gridtext_0.1.5
## [70] grid_4.5.1 checkmate_2.3.3 reshape2_1.4.5
## [73] generics_0.1.4 gtable_0.3.6 tzdb_0.5.0
## [76] data.table_1.18.0 hms_1.1.4 car_3.1-3
## [79] xml2_1.5.2 ggrepel_0.9.8 pillar_1.11.1
## [82] splines_4.5.1 ggtext_0.1.2 lattice_0.22-7
## [85] showtextdb_3.0 survival_3.8-3 tidyselect_1.2.1
## [88] fontLiberation_0.1.0 fontBitstreamVera_0.1.1 gridExtra_2.3
## [91] xfun_0.56 devtools_2.5.2 rapportools_1.2
## [94] randtests_1.0.2 matrixStats_1.5.0 stringi_1.8.7
## [97] yaml_2.3.12 evaluate_1.0.5 codetools_0.2-20
## [100] officer_0.7.6 tcltk_4.5.1 gdtools_0.5.1
## [103] multcompView_0.1-12 cli_3.6.5 xtable_1.8-8
## [106] systemfonts_1.3.1 jquerylib_0.1.4 Rcpp_1.1.2
## [109] summarytools_1.1.5 parallel_4.5.1 leaps_3.2
## [112] ellipsis_0.3.3 cubature_2.1.4-1 mvtnorm_1.3-3
## [115] sysfonts_0.8.9 crayon_1.5.3 flextable_0.10.0
## [118] rlang_1.3.0 multcomp_1.4-29 mnormt_2.1.1