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:

1) Cargue y preparación de datos

1.1 Carga y Vista rápida

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)

1.2 Diagnóstico de valores faltantes

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

1.3 Filtrado de NA’s+reporte de cuántas filas se pierden

# 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)
)

1.4 Estandarización (media 0, sd 1)+verificación

# 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

2) Base para EDA (numéricas+categóricas clave)

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.

2.1 Estadísticos descriptivos (Variables numéricas)

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")
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

2.2 Distribuciones básicas

# 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")

2.3 Precios por estrato y zona (boxplots)

# 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)")

2.4 Correlaciones entre numéricas

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)")

2.5 Relación precio-área (por estrato)

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)")

2.6 Categóricas: Oferta por tipo y zona

# 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")

2.7 Vistazo geográfico (dispersión lat-long coloreada por precio)

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

3) Análisis de Componentes Principales (ACP)

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.

4) Análisis de Conglomerados (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).

5) Análisis de Correspondencia (CA)

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.

6) Visualización de resultados

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.

6.1 Gráficos descriptivos clave

# 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.

6.2 Mapa interactivo de la oferta (con clusters)

# 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.

7) Conclusiones

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.

8) Recomendaciones

  • Dirigir campañas publicitarias específicas para cada segmento detectado.
  • Considerar inversión en barrios emergentes cercanos a zonas de alto crecimiento.
  • Enfocar estrategias de captación de clientes en estratos 4–5 donde la oferta y demanda están equilibradas.