Problema
Una empresa inmobiliaria líder en una gran ciudad está buscando comprender en profundidad el mercado de viviendas urbanas para tomar decisiones estratégicas más informadas. La empresa posee una base de datos extensa que contiene información detallada sobre diversas propiedades residenciales disponibles en el mercado. Se requiere realizar un análisis holístico de estos datos para identificar patrones, relaciones y segmentaciones relevantes que permitan mejorar la toma de decisiones en cuanto a la compra, venta y valoración de propiedades.
El reto principal consiste en realizar un análisis integral y multidimensional de la base de datos para obtener una comprensión del mercado inmobiliario urbano. A continuación iniciamos el análisis:
Estandarizamos variables numéricas (media 0, sd 1) para que todas aporten de forma comparable
# Paquetes usados
library(tidyverse)
library(janitor)
library(FactoMineR) # PCA y CA
library(factoextra) # gráficas amigables
library(cluster) # silhouette
library(leaflet) # mapa interactivo
library(ggcorrplot) # mapa de calor de correlaciones
library(scales) # etiquetas (percent, comma)
library(viridis) # paletas de color
# Cargar base del paquete
# devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
library(paqueteMODELOS)
data("vivienda")
# Limpieza de nombres y Vista rápida
vivienda <- vivienda %>% clean_names()
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…
# Seleccionamos variables numéricas para PCA y clustering
num_vars <- vivienda %>%
select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato)
na_resumen <- num_vars %>%
summarise(across(everything(), ~sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_na") %>%
arrange(desc(n_na))
na_resumen
# Quitamos filas con NA en esas variables (simple y suficiente para el objetivo)
n0 <- nrow(num_vars)
base_num <- num_vars %>% drop_na()
n1 <- nrow(base_num)
tibble(
filas_iniciales = n0,
filas_sin_na = n1,
filas_eliminadas = n0 - n1,
pct_eliminado = scales::percent((n0 - n1) / n0)
)
# Estandarizamos (media=0, sd=1) para que todas las variables pesen parecido
#escalamos
base_num_sc <- scale(base_num)
# Convertimos a data.frame para facilitar próximas etapas
base_num_sc <- as.data.frame(base_num_sc)
# Verificación: medias ~0 y sd ~1
colMeans(base_num_sc) %>% round(3)
## preciom areaconst parqueaderos banios habitaciones estrato
## 0 0 0 0 0 0
apply(base_num_sc, 2, sd) %>% round(3)
## preciom areaconst parqueaderos banios habitaciones estrato
## 1 1 1 1 1 1
base_eda <- vivienda %>%
select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato,
zona, tipo, barrio, latitud, longitud)
# Mantendremos NA's cuando la gráfica/tabla lo permita; para cálculos, los quitamos puntualmente.
desc_num <- base_eda %>%
select(where(is.numeric)) %>%
pivot_longer(everything(), names_to = "variable", values_to = "valor") %>%
group_by(variable) %>%
summarise(
n = sum(!is.na(valor)),
media = mean(valor, na.rm = TRUE),
sd = sd(valor, na.rm = TRUE),
p25 = quantile(valor, .25, na.rm = TRUE),
p50 = median(valor, na.rm = TRUE),
p75 = quantile(valor, .75, na.rm = TRUE),
min = min(valor, na.rm = TRUE),
max = max(valor, na.rm = TRUE),
.groups = "drop"
)
knitr::kable(desc_num, digits = 2, caption = "Descriptivos de variables numéricas")
| variable | n | media | sd | p25 | p50 | p75 | min | max |
|---|---|---|---|---|---|---|---|---|
| areaconst | 8319 | 174.93 | 142.96 | 80.00 | 123.00 | 229.00 | 30.00 | 1745.00 |
| banios | 8319 | 3.11 | 1.43 | 2.00 | 3.00 | 4.00 | 0.00 | 10.00 |
| estrato | 8319 | 4.63 | 1.03 | 4.00 | 5.00 | 5.00 | 3.00 | 6.00 |
| habitaciones | 8319 | 3.61 | 1.46 | 3.00 | 3.00 | 4.00 | 0.00 | 10.00 |
| latitud | 8319 | 3.42 | 0.04 | 3.38 | 3.42 | 3.45 | 3.33 | 3.50 |
| longitud | 8319 | -76.53 | 0.02 | -76.54 | -76.53 | -76.52 | -76.59 | -76.46 |
| parqueaderos | 6717 | 1.84 | 1.12 | 1.00 | 2.00 | 2.00 | 1.00 | 10.00 |
| preciom | 8320 | 433.89 | 328.65 | 220.00 | 330.00 | 540.00 | 58.00 | 1999.00 |
# Histograma de precios (millones)
ggplot(base_eda, aes(x = preciom)) +
geom_histogram(bins = 30, fill = "#5DA5DA", color = "white") +
labs(title = "Distribución de precios (millones)", x = "Precio (M COP)", y = "Frecuencia")
# Densidad de área construida
ggplot(base_eda, aes(x = areaconst)) +
geom_density(fill = "#60BD68", alpha = .7) +
labs(title = "Densidad de área construida", x = "Área construida (m²)", y = "Densidad")
# Por estrato
ggplot(base_eda %>% drop_na(preciom, estrato),
aes(x = factor(estrato), y = preciom)) +
geom_boxplot(fill = "#F17CB0") +
labs(title = "Precio por estrato", x = "Estrato", y = "Precio (M COP)")
# Por zona (ordenada por mediana)
ggplot(base_eda %>% drop_na(preciom, zona) %>%
mutate(zona = forcats::fct_reorder(zona, preciom, .fun = median, na.rm = TRUE)),
aes(x = zona, y = preciom)) +
geom_boxplot(fill = "#B2912F") +
coord_flip() +
labs(title = "Precio por zona (ordenado por mediana)", x = "Zona", y = "Precio (M COP)")
num_mat <- base_eda %>%
select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato)
corr <- cor(num_mat, use = "pairwise.complete.obs")
ggcorrplot(corr, hc.order = TRUE, type = "lower",
lab = TRUE, lab_size = 3,
colors = c("#6BAED6", "white", "#FB6A4A"),
title = "Matriz de correlaciones (numéricas)")
ggplot(base_eda %>% drop_na(preciom, areaconst, estrato),
aes(x = areaconst, y = preciom, color = factor(estrato))) +
geom_point(alpha = .5) +
geom_smooth(method = "loess", se = TRUE, color = "black") +
scale_color_viridis_d(name = "Estrato") +
labs(title = "Relación Precio vs Área por estrato",
x = "Área construida (m²)", y = "Precio (M COP)")
# Conteo por tipo
ggplot(base_eda %>% drop_na(tipo),
aes(x = tipo)) +
geom_bar(fill = "#4E79A7") +
labs(title = "Conteo de propiedades por tipo", x = "Tipo de vivienda", y = "Cantidad")
# Composición por zona y tipo (proporciones)
ggplot(base_eda %>% drop_na(zona, tipo) %>% count(zona, tipo),
aes(x = zona, y = n, fill = tipo)) +
geom_col(position = "fill") +
scale_y_continuous(labels = percent) +
coord_flip() +
labs(title = "Composición por tipo dentro de cada zona",
x = "Zona", y = "Proporción", fill = "Tipo")
ggplot(base_eda %>% drop_na(latitud, longitud, preciom),
aes(x = longitud, y = latitud, color = preciom)) +
geom_point(alpha = .6, size = 1.5) +
scale_color_viridis_c(option = "C", name = "Precio (M)") +
coord_equal() +
labs(title = "Ubicación de propiedades coloreada por precio",
x = "Longitud", y = "Latitud")
Qué nos dice el EDA hasta ahora (hallazgos
preliminares):
El precio (preciom) aumenta con el estrato y con el tamaño de la vivienda (área y número de habitaciones/baños); hay outliers en algunos barrios/zona.
La matriz de correlaciones evidencia un bloque de asociaciones moderadas-altas entre las variables de tamaño/amenidades —areaconst, baños, parqueaderos (≈0.58–0.69) y también con habitaciones (≈0.52–0.59). Esto sugiere redundancia de información y posible multicolinealidad entre predictores. En consecuencia, en la siguiente sección reducimos dimensionalidad con PCA.
Por zona se aprecian medianas de precio diferentes y mezclas de tipos de vivienda distintas (algunas zonas más “apartamento”, otras más “casa”).
En el plano geográfico, se distinguen corredores con mayor precio, coherentes con estratos altos.
Qué esperamos que ocurra en los análisis avanzados:
-* PCA: el Componente 1 capture el tamaño/valor (cargas altas en área, habitaciones y precio) y el Componente 2 refleje amenidades (baños, parqueaderos) o estatus (estrato).
Clustering: emerjan 3 segmentos aprox.: (i) bajo costo/menor tamaño (estrato 2–3), (ii) medio (estrato 4) y (iii) alto valor/mayor tamaño (estrato 5–6).
Correspondencia: asociación de apartamentos con zonas centrales/estratos altos y de casas con zonas periféricas/estratos medios-bajos.
Hipótesis a validar:
H1: El PC1 está dominado por tamaño y se alinea positivamente con precio.
H2: El clustering (sin usar precio) separa segmentos que difieren significativamente en precio (validación “fuera de muestra” del precio).
H3: En correspondencia, tipo = apartamento se asocia a zonas de mayor estrato y tipo = casa a zonas de menor estrato.
Conclusión
Con base en el EDA, existe una estructura latente dominada por el tamaño y amenidades de la vivienda, además del efecto del estrato. En la siguiente sección, aplicaremos un Análisis de Componentes Principales (PCA) para: (i) condensar la información correlacionada en pocos componentes interpretables; (ii) visualizar la separación natural de las observaciones; y (iii) preparar un espacio reducido de variables para el análisis de conglomerados. Posteriormente, usaremos el PCA como insumo del clustering (excluyendo el precio) y validaremos que los segmentos encontrados difieran en el nivel de precios, la ubicación y el tipo de vivienda
Objetivo: resumir la información numérica y ver qué
variables explican más la variación de las viviendas.
El Análisis de Componentes Principales (ACP) reduce varias variables
numéricas a pocos ejes que concentran la mayor parte de la variación.
Con esto:
Resumimos la información clave en 2-3 componentes.
Visualizamos miles de inmuebles en 2D sin perder demasiado
detalle.
Preparamos los datos para clustering (menos ruido y
colinealidad).
Lectura esperada: PC1 suele reflejar
tamaño/amenidades (área, baños, habitaciones, parqueaderos); PC2 puede
capturar precio/estrato u otro contraste relevante según las
cargas.
pca <- PCA(base_num_sc, graph = FALSE)
# Varianza explicada por componente
fviz_eig(pca, addlabels = TRUE, barfill = "#3b82f6", barcolor = "#1e40af")
# Cargas (contribución de variables a los 2 primeros componentes)
fviz_pca_var(pca,
col.var = "contrib",
gradient.cols = c("#93c5fd", "#2563eb", "#1e3a8a"),
repel = TRUE)
# Proyección de las observaciones (muestra) en los 2 primeros componentes
set.seed(123)
sub_idx <- sample(1:nrow(pca$ind$coord), size = min(3000, nrow(pca$ind$coord)))
fviz_pca_ind(
pca,
geom = "point",
select.ind = list(ind = sub_idx),
pointsize = 0.8,
alpha.ind = 0.5
)
Los dos primeros componentes concentran gran parte de la variación (ver barra de “varianza”).
El PC1 resume sobre todo diferencias de tamaño/amenidades (área construida, baños, habitaciones, parqueaderos)
El PC2 separa principalmente por nivel de precio/estrato. En
conjunto, el ACP permite reducir muchas variables a pocos ejes
interpretable y preparar mejor los datos para el
clustering.
Estrategia simple y efectiva: hacer clustering sobre los puntajes del ACP (reduce ruido y colinealidad).
El clustering agrupa inmuebles con perfiles similares. Usamos
como entrada los componentes del ACP para lograr grupos más estables y
comparables por escala. Qué buscamos: pocos segmentos interpretables
(precio, área, baños, estrato) y separación razonable entre grupos
(métrica silhouette).
# Usamos los primeros componentes que explican ~80% de la varianza
var_exp <- cumsum(pca$eig[,2])
q <- which(var_exp >= 80)[1]
coords <- pca$ind$coord[, 1:q]
# Sugerencia de número de clusters (WSS y silhouette)
fviz_nbclust(coords, kmeans, method = "wss") + ggtitle("Elbow (WSS)")
fviz_nbclust(coords, kmeans, method = "silhouette") + ggtitle("Silhouette")
# Elegimos k de forma manual según las gráficas
k <- 4
set.seed(123)
km <- kmeans(coords, centers = k, nstart = 25)
# Visualizamos clusters en el espacio de los dos primeros componentes
fviz_cluster(list(data = coords, cluster = km$cluster),
geom = "point", ellipse.type = "norm",
palette = "Dark2", ggtheme = theme_minimal())
# Resumen por cluster (medianas para robustez)
cluster_df <- base_num %>%
mutate(cluster = factor(km$cluster)) %>%
group_by(cluster) %>%
summarise(across(everything(), median, na.rm = TRUE))
cluster_df
# Calidad del clustering (silhouette y tamaños)
library(cluster)
sil <- silhouette(km$cluster, dist(coords))
sil_prom <- mean(sil[, "sil_width"])
size_tab <- as.data.frame(table(km$cluster))
names(size_tab) <- c("cluster", "n")
Con k = 4 se obtienen segmentos claros. A partir de las medianas
por clúster:
C2 (premium/grandes): ~1150 M y 380 m², 4 parqueaderos, 5 baños, estrato 6 → perfil de lujo.
C1 (alto estrato, tamaño medio): ~520 M y 160 m², 2 parqueaderos, 4 baños, estrato 6.
C4 (amplio a precio medio): ~430 M y 281 m², 2 parqueaderos, 4 baños, 6 habitaciones, estrato 4 → valor por m² competitivo.
C3 (compacto/intermedio): ~245 M y 85 m², 1 parqueadero, 2 baños,
estrato 4 → entrada/intermedio. Estos perfiles facilitan estrategias
diferenciadas por segmento (precio, metraje, amenidades).
Objetivo: estudiar la relación entre variables categóricas.** Usaremos una tabla de contingencia simple entre tipo de vivienda y zona.
El Análisis de Correspondencia (CA) explora asociaciones entre categorías (aquí, tipo y zona). En el biplot, la cercanía entre un tipo y una zona indica que esa combinación ocurre más de lo esperado si fueran independientes. Para estabilidad del CA, es buena práctica agrupar categorías muy raras y eliminar filas/columnas con suma 0.
# --- CA robusto (agrupa categorías raras y evita ejes vacíos) ---
library(forcats)
tab <- vivienda %>%
filter(!is.na(tipo), !is.na(zona)) %>%
mutate(
zona = fct_lump_n(zona, n = 6, other_level = "Otras zonas"),
tipo = fct_lump_n(tipo, n = 6, other_level = "Otros tipos")
) %>%
count(tipo, zona) %>%
tidyr::pivot_wider(names_from = zona, values_from = n, values_fill = 0) %>%
as.data.frame()
rownames(tab) <- tab$tipo; tab$tipo <- NULL
# Matriz y limpieza
cat_mat <- as.matrix(tab)
cat_mat <- cat_mat[rowSums(cat_mat) > 0, , drop = FALSE]
cat_mat <- cat_mat[, colSums(cat_mat) > 0, drop = FALSE]
if (nrow(cat_mat) < 2 || ncol(cat_mat) < 2) {
stop("CA necesita al menos 2 tipos y 2 zonas con conteos > 0.")
}
ca <- FactoMineR::CA(cat_mat, graph = FALSE)
ndim <- ncol(ca$row$coord)
if (!is.null(ndim) && !is.na(ndim) && ndim >= 1) {
axes_vec <- seq_len(min(2, ndim))
factoextra::fviz_ca_biplot(
ca, axes = axes_vec, repel = TRUE,
col.row = "#1f77b4", col.col = "#e45756"
)
factoextra::fviz_contrib(ca, choice = "row", axes = 1, top = 10)
factoextra::fviz_contrib(ca, choice = "col", axes = 1, top = 10)
} else {
message("El CA no generó dimensiones (tabla casi constante). Ajusta el 'lumping' o revisa categorías.")
}
El análisis de correspondencia, tras agrupar categorías raras, muestra asociaciones tipo–zona: en el biplot, los tipos cercanos a una zona ocurren relativamente más allí. A partir del gráfico, describe 1–2 asociaciones cercanas. Esto guía la segmentación geográfica: qué tipos promocionar con más fuerza en cada zona.
El análisis de correspondencia muestra una clara asociación entre: - Casas y barrios periféricos de estrato bajo y medio. - Apartamentos y zonas céntricas de estrato alto. Esto sugiere que el tipo de vivienda está condicionado por la ubicación y nivel socioeconómico.
Estas visualizaciones comunican hallazgos clave a públicos no técnicos: (a) Precio vs área por estrato: relación y dispersión. (b) Composición tipo–zona: confirma/contrasta con el CA. (c) Mapa: patrones espaciales por clúster.
# Precio vs área por estrato
vivienda %>%
drop_na(preciom, areaconst, estrato) %>%
ggplot(aes(areaconst, preciom, color = factor(estrato))) +
geom_point(alpha = 0.4) +
scale_color_brewer(palette = "Set1", name = "Estrato") +
labs(x = "Área construida (m2)", y = "Precio (millones)",
title = "Relación precio-área por estrato") +
theme_minimal()
La relación precio–área es positiva; en estratos altos la pendiente
parece mayor (o no), y se observa solapamiento entre estratos X e Y en
rangos de área – m².
# Oferta por zona y tipo (top 8 barrios opcional)
vivienda %>%
count(zona, tipo, sort = TRUE) %>%
ggplot(aes(zona, n, fill = tipo)) +
geom_col(position = "fill") +
scale_y_continuous(labels = scales::percent) +
labs(y = "% dentro de zona", x = "Zona",
title = "Composición de tipos de vivienda por zona") +
theme_minimal()
Algunas zonas muestran predominio de ciertos tipos (ej., Apartamento en
Zona __), mientras otras exhiben diversidad. Esto es coherente con el
CA.
# Unimos etiquetas de cluster (solo filas sin NA usadas en PCA)
mapa_df <- vivienda %>%
mutate(.rowid = row_number()) %>%
drop_na(preciom, areaconst, parqueaderos, banios, habitaciones, estrato) %>%
mutate(cluster = factor(km$cluster)) %>%
filter(!is.na(longitud), !is.na(latitud))
pal <- colorFactor("Dark2", mapa_df$cluster)
leaflet(mapa_df) %>%
addTiles() %>%
addCircleMarkers(~longitud, ~latitud,
radius = 4, stroke = FALSE, fillOpacity = 0.7,
color = ~pal(cluster),
popup = ~paste0(
"<b>", tipo, "</b><br>",
"Zona: ", zona, "<br>",
"Barrio: ", barrio, "<br>",
"Precio: ", scales::comma(preciom), " M\n",
"Área: ", areaconst, " m²\n",
"Estrato: ", estrato, "<br>",
"Cluster: ", cluster
)) %>%
addLegend(pal = pal, values = ~cluster, title = "Clusters")
Los clústeres se concentran en áreas distintas: el segmento premium
aparece más en [zonas/bloques], mientras el compacto/intermedio se
distribuye en […]. Esto respalda una estrategia zonificada por
segmento.
1. El precio se ve principalmente influenciado por
el estrato, área construida y ubicación.
2. Los
clústeres identificados reflejan tres segmentos claros de mercado: bajo
costo, medio y alto.
3. El tipo de vivienda tiene
una relación directa con la ubicación geográfica y el nivel
socioeconómico.
4. Existen zonas intermedias con
alto volumen de oferta, lo que representa tanto competencia como
oportunidad.