1 Evaluación de la oferta inmobiliaria urbana

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.

Este informe presenta un análisis holístico de la base datos con variables de propiedades residenciales urbanas de la ciudad de Cali, para el análñisis se realizara lo siguiente:

  • PCA (reducción de dimensionalidad e interpretación de variables)
  • Clustering (segmentación de la oferta)
  • Análisis de Correspondencias (CA) entre variables categóricas
  • Visualizaciones (gráficos y mapa simple con lat/long)

1.1 1.1 Librerías

#install.packages(c("tidyverse","janitor","FactoMineR","factoextra",
#                   "cluster","NbClust","ggrepel","kableExtra"))
#install.packages("plotly")
#install.packages("quantmod")
library(tidyverse)
library(readr)
library(janitor)
library(scales)
library(FactoMineR)
library(factoextra)
library(cluster)
library(NbClust)
library(ggrepel)
library(knitr)
library(kableExtra)
library(plotly)
library(quantmod)
library(corrplot)

1.2 Carga y preparación

library(paqueteMODELOS)

data("vivienda")      # Esto carga el objeto 'vivienda' en el entorno global
viv <- vivienda       # Ahora sí viv es un data frame
write.csv(vivienda, "vivienda.csv", row.names = FALSE)
getwd()
## [1] "/Users/marioleandro/Documents/Maestría Ciencia de datos/Estadistica 2/Unidad1/Unidad2_e2_files/vfinal"

1.3 Limpieza básica e ingeniería de variables

  • Convertir piso a numérico seguro
  • Filtrar registros con preciom y areaconst > 0
  • Crear precio_m2 = preciom / areaconst
  • Imputar faltantes numéricos con la mediana
to_num_safe <- function(x){
  suppressWarnings(as.numeric(gsub(",", ".", trimws(as.character(x)))))
}

viv <- viv %>%
  mutate(
    piso_num  = to_num_safe(piso),
    precio_m2 = preciom / areaconst
  ) %>%
  filter(preciom > 0, areaconst > 0) %>%
  distinct(id, .keep_all = TRUE)

num_cols <- c("estrato","preciom","areaconst","parqueaderos","banios","habitaciones","piso_num","precio_m2")
cat_cols <- c("zona","tipo","barrio")

# Imputación por mediana
viv <- viv %>%
  mutate(across(all_of(num_cols), ~ ifelse(is.na(.x), median(.x, na.rm = TRUE), .x)))

1.4 Revisión inicial.

A continuación se presentan las tablas resumen donde se pueden caracterizar las variables.

summary_num <- viv %>%
  select(all_of(num_cols)) %>%
  summary()

freq_zona <- viv %>% count(zona, sort = TRUE) %>% slice_head(n = 10)
freq_tipo <- viv %>% count(tipo, sort = TRUE)
freq_barrio <- viv %>% count(barrio, sort = TRUE) %>% slice_head(n = 20)

kable(freq_zona, caption = "Frecuencia por Zona (Top 10)") %>% kable_styling(full_width = FALSE)
Frecuencia por Zona (Top 10)
zona n
Zona Sur 4726
Zona Norte 1920
Zona Oeste 1198
Zona Oriente 351
Zona Centro 124
kable(freq_tipo, caption = "Frecuencia por Tipo de Vivienda") %>% kable_styling(full_width = FALSE)
Frecuencia por Tipo de Vivienda
tipo n
Apartamento 5100
Casa 3219
kable(freq_barrio, caption = "Frecuencia por Barrio (Top 20)") %>% kable_styling(full_width = FALSE)
Frecuencia por Barrio (Top 20)
barrio n
valle del lili 1008
ciudad jardín 516
pance 409
la flora 366
santa teresita 262
el caney 208
el ingenio 202
la hacienda 164
acopi 158
los cristales 154
normandía 154
el limonar 135
prados del norte 126
el refugio 120
aguacatal 109
ciudad 2000 95
caney 88
cristales 83
urbanización la flora 83
brisas de los 81

Al revisar el grafico de velas, se observan que existen algunos posible outlier, sin embargo y por el contexto de los precios, debe validarse si los precios son reales o se deben a algun error de escritura, al igual que las áreas asociadas para las viviendas.

viv2 <- viv%>% 
  select_if(is.numeric)%>%
  select(-id,-longitud,-latitud)

num_cols <- viv2 %>% select(where(is.numeric)) %>% names()
viv_long <- viv2 %>%
  select(all_of(num_cols)) %>%
  pivot_longer(cols = everything(), names_to = "variable", values_to = "valor")
# Gráfico tipo vela/boxplot
ggplot(viv_long, aes(x = variable, y = valor)) +
  geom_boxplot(outlier.color = "red", fill = "lightblue") +
  coord_flip() +  # Girar para que sea horizontal
  labs(title = "Boxplots para variables numéricas - Base vivienda",
       x = "Variable", y = "Valor") +
  theme_minimal()

2 1. PCA (Análisis de Componentes Principales)

Para poder aplicar PCA, se debe tener en cuenta lo siguiente. - No se deben tener datos faltantes, de acuerdo con el análisis que se realice, se definirá si debemos imputar los datos por alguna medida estadística o mantenerlos como nulos. ## Limpieza de datos - Se debe garantizar que los datos sean numéricos, por esta razón se deben seleccionar las variables numéricas unicamente. - Se debe realizar un escalado de variables, esto llevará a que todas las variables tengan como media cero y desviación estandar 1, esto ayudará a comprender merjor los datos que estan en diferentes unidades de medida.

Estandarizamos las variables numéricas y aplicamos PCA, tambien se muestran las varianzas de cada dimensión

Se procede entonces con la limpieza de datos, inicialmente se validan los datos faltantes de las variables numéricas

library(mice)
md.pattern(viv)

##      id zona estrato preciom areaconst parqueaderos banios habitaciones tipo
## 5684  1    1       1       1         1            1      1            1    1
## 2635  1    1       1       1         1            1      1            1    1
##       0    0       0       0         0            0      0            0    0
##      barrio longitud latitud piso_num precio_m2 piso     
## 5684      1        1       1        1         1    1    0
## 2635      1        1       1        1         1    0    1
##           0        0       0        0         0 2635 2635

Se elimina la unica variable vacia que se tiene en piso, esto se realiza porque no es representativo el valor frente al resto de los datos.

 viv <- viv[!is.na(viv$piso), ]

comprobamos que la base de datos no contenga valores nulos.

library(mice)
md.pattern(viv)
##  /\     /\
## {  `---'  }
## {  O   O  }
## ==>  V <==  No need for mice. This data set is completely observed.
##  \  \|/  /
##   `-----'

##      id zona piso estrato preciom areaconst parqueaderos banios habitaciones
## 5684  1    1    1       1       1         1            1      1            1
##       0    0    0       0       0         0            0      0            0
##      tipo barrio longitud latitud piso_num precio_m2  
## 5684    1      1        1       1        1         1 0
##         0      0        0       0        0         0 0

Con los datos sin valores nulos, se procede a realizar el escalamiento para posteriormente encontrar las dimensiones representativas y las varianzas asociadas a cada un, para el análisis de pca no tendremos en cuenta los ID ni la longitud ni latitud

X <- scale(viv %>% select(all_of(num_cols)))
pca <- PCA(X, graph = FALSE)

# Varianza explicada
eig_df <- get_eigenvalue(pca)
kable(eig_df, digits = 3, caption = "Autovalores y varianza explicada") %>% 
  kable_styling(full_width = FALSE)
Autovalores y varianza explicada
eigenvalue variance.percent cumulative.variance.percent
Dim.1 3.505 43.807 43.807
Dim.2 2.025 25.314 69.122
Dim.3 0.817 10.209 79.330
Dim.4 0.615 7.691 87.022
Dim.5 0.441 5.507 92.529
Dim.6 0.303 3.784 96.313
Dim.7 0.230 2.876 99.189
Dim.8 0.065 0.811 100.000

2.1 1.1 Explicación de la varianza.

Se realiza un grafico donde se representen las varianzas de cada una de las dimensioes y a partir de estas se puede definir la cantidad de dimensiones necesarias, para este caso se seleccionan 2 dimensiones que son las más representativas respecto al porcentaje de las varianzas.

fviz_eig(pca, addlabels = TRUE, barcolor = "grey30", barfill = "grey70")

2.2 1.2 Grafico de dimensiones

fviz_pca_ind(pca, geom = "point", pointsize = 0.5, alpha.ind = 0.5)

2.3 1.3 Círculo de correlaciones (variables) con contribuciones

fviz_pca_var(
  pca,
  col.var = "contrib",
  gradient.cols = c("#FF7F00", "#034D94"),
  repel = TRUE
) + ggtitle("Variables - PCA")

2.4 1.4 Cargas (loadings) y top contribuciones por componente

A continuación se presenta la tabla de cargas de variables según las dimensiones, donde se puede observar que para la dimensión 1 el precio y la cantidad de baños tienen el mayor aporte, y para la dimensión 2 lo tiene el número de habitaciones y el estrato.

var <- get_pca_var(pca)
loadings <- as.data.frame(var$coord) %>% rownames_to_column("variable")
contrib <- as.data.frame(var$contrib) %>% rownames_to_column("variable")

top_pc1 <- contrib %>% arrange(desc(Dim.1)) %>% slice_head(n = 5) %>% select(variable, Dim.1)
top_pc2 <- contrib %>% arrange(desc(Dim.2)) %>% slice_head(n = 5) %>% select(variable, Dim.2)

kable(loadings, digits = 3, caption = "Cargas (coeficientes) de variables en PC1-PC2") %>% 
  kable_styling(full_width = FALSE)
Cargas (coeficientes) de variables en PC1-PC2
variable Dim.1 Dim.2 Dim.3 Dim.4 Dim.5
estrato 0.572 0.639 -0.150 0.191 -0.297
preciom 0.892 0.282 -0.060 -0.039 -0.017
areaconst 0.845 -0.318 0.067 -0.130 -0.318
parqueaderos 0.741 0.032 -0.158 -0.551 0.293
banios 0.872 -0.076 0.162 0.225 0.050
habitaciones 0.571 -0.568 0.326 0.333 0.266
piso_num -0.147 0.558 0.785 -0.220 -0.050
precio_m2 0.112 0.892 -0.114 0.218 0.299
kable(top_pc1, digits = 2, caption = "Top 5 contribuciones a PC1") %>% kable_styling(full_width = FALSE)
Top 5 contribuciones a PC1
variable Dim.1
preciom 22.71
banios 21.69
areaconst 20.36
parqueaderos 15.66
estrato 9.32
kable(top_pc2, digits = 2, caption = "Top 5 contribuciones a PC2") %>% kable_styling(full_width = FALSE)
Top 5 contribuciones a PC2
variable Dim.2
precio_m2 39.29
estrato 20.15
habitaciones 15.91
piso_num 15.39
areaconst 4.98

3 2. Clustering (K-Means)

Usamos los componentes o las variables estandarizadas. Aquí empleamos X escalado completo para segmentar. Elegimos k vía silhouette y método del codo, donde como resultado tenemos que con dos clusters podemos tener una clasificación adecuada.

# Elbow & silhouette con factoextra
p1 <- fviz_nbclust(X, kmeans, method = "wss") + ggtitle("Método del codo")
p2 <- fviz_nbclust(X, kmeans, method = "silhouette") + ggtitle("Silhouette")

p1

p2

# k óptimo: máximo silhouette 
sil_scores <- sapply(2:8, function(k){
  pamk <- kmeans(X, centers = k, nstart = 20, iter.max = 100)
  ss <- silhouette(pamk$cluster, dist(X))
  mean(ss[, 3])
})
best_k <- ifelse(all(is.na(sil_scores)), 4, which.max(sil_scores) + 1)

km <- kmeans(X, centers = best_k, nstart = 50, iter.max = 200)
viv$cluster <- factor(km$cluster)

best_k
## [1] 2

3.1 3.1 Perfil de clusters

A continuación se prsenta un resumen de cada cluster, del cual es posible concluir que:

•   Cluster 1:
•   Viviendas de lujo o alta gama: casas grandes, alto estrato, alto precio y mucha dotación.
•   Claramente un segmento premium en el mercado.
•   Cliente objetivo: familias con alto poder adquisitivo, buscando espacio y exclusividad.
•   Cluster 2:
•   Viviendas más accesibles: apartamentos, menor tamaño, menos dotación, precio medio.
•   Estrato medio-alto pero con un rango de inversión menor.
•   Cliente objetivo: parejas jóvenes o familias pequeñas buscando comodidad sin pagar un precio alto.
profile_num <- viv %>% 
  group_by(cluster) %>% 
  summarise(across(all_of(num_cols), ~ round(mean(.x, na.rm = TRUE), 2)))

mode2 <- function(x){
  ux <- na.omit(x)
  if(length(ux) == 0) return(NA_character_)
  ux[which.max(tabulate(match(x, ux)))]
}

profile_cat <- viv %>%
  group_by(cluster) %>%
  summarise(across(all_of(cat_cols), mode2))

profile <- profile_num %>% left_join(profile_cat, by = "cluster")

kable(profile, caption = paste0("Perfil de clusters (k = ", best_k, ")")) %>% 
  kable_styling(full_width = FALSE)
Perfil de clusters (k = 2)
cluster estrato preciom areaconst parqueaderos banios habitaciones piso_num precio_m2 zona tipo barrio
1 5.38 788.82 309.49 2.73 4.70 4.63 3.28 2.93 Zona Sur Casa ciudad jardín
2 4.36 271.36 108.79 1.47 2.41 3.11 3.98 2.68 Zona Sur Apartamento valle del lili

3.2 2.1 PCA coloreado por cluster

Basado en las dimensiones de PCA, los cluster se pueden observar a continuación

fviz_pca_ind(pca, geom = "point", pointsize = 0.6,
             habillage = viv$cluster, addEllipses = TRUE, ellipse.level = 0.95)

4 3. Análisis de Correspondencias (CA)

top20_barrio <- viv %>% count(barrio, sort = TRUE) %>% slice_head(n = 20) %>% pull(barrio)
sub <- viv %>% filter(barrio %in% top20_barrio)
tab_bz <- table(sub$barrio, sub$zona)
ca_bz <- CA(tab_bz, graph = FALSE)
fviz_ca_biplot(ca_bz, repel = TRUE, col.row = "#1f77b4", col.col = "#ff7f0e") +
  ggtitle("CA: Barrio (Top20) vs Zona")

5 4. Graficos de análisis.

Al realizar un análisis del grafico de correlaciones, se observa que no hay correlaciones relevantes entre las variables, esto siempre y cuando no se tenga en cuenta el ID, pues spoiblemente se deba a un orden que se haya dado previamente de acuerdo con la ubicación geografica.

4.1 Matriz de correlaciones

# Seleccionar solo numéricas
num_cols <- viv %>% select(where(is.numeric))
num_mat <- as.matrix(num_cols)
# Matriz de correlación
cor_mat <- cor(num_mat, use = "pairwise.complete.obs", method = "pearson")

# Visualizar
corrplot(cor_mat, method = "color", type = "upper",
         tl.col = "black", tl.srt = 45,
         addCoef.col = "black", number.cex = 0.7)

Mostramos la dispersión por longitud/latitud (sin base cartográfica), teniendo en cuenta la ubicación de los cluster. Al analizar el mapa, se observa que aunque en algunas zonas predominan algunos cluster, en el centro de la ciudad tiende a tener mezclas grades de ellos, lo que indica que existen viviendas cercanas de estratos muy diferentes.

sample_n <- min(nrow(viv), 3000)
viv_map <- viv %>% slice_sample(n = sample_n)
ggplot(viv_map, aes(x = longitud, y = latitud, color = cluster)) +
  geom_point(alpha = 0.6, size = 0.7) +
  coord_equal() +
  labs(title = "Ubicaciones por cluster", x = "Longitud", y = "Latitud") +
  guides(color = guide_legend(override.aes = list(size = 3, alpha = 1)))