1 Resumen ejecutivo

Este informe analiza la oferta de vivienda urbana disponible en la base vivienda, obtenida mediante web scraping de OLX y distribuida para la asignatura. El propósito es transformar la información de las propiedades en evidencia para la compra, venta y valoración inmobiliaria.

Se utilizan tres técnicas complementarias:

  1. Análisis de Componentes Principales (ACP): identifica las dimensiones que resumen la variabilidad de las características numéricas de los inmuebles.
  2. Análisis de conglomerados: segmenta la oferta en grupos homogéneos para facilitar decisiones comerciales y de inversión.
  3. Análisis de correspondencia: estudia la asociación entre el tipo de vivienda, la zona y el barrio.

Pregunta de negocio. ¿Qué perfiles de vivienda predominan en el mercado, cómo se agrupan espacial y económicamente, y qué combinaciones de tipo y ubicación permiten orientar la estrategia de la inmobiliaria?

2 1. Entendimiento y preparación de los datos

dim(vivienda)
## [1] 8322   13
str(vivienda)
## spc_tbl_ [8,322 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
##  $ id          : num [1:8322] 1147 1169 1350 5992 1212 ...
##  $ zona        : chr [1:8322] "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
##  $ piso        : chr [1:8322] NA NA NA "02" ...
##  $ estrato     : num [1:8322] 3 3 3 4 5 5 4 5 5 5 ...
##  $ preciom     : num [1:8322] 250 320 350 400 260 240 220 310 320 780 ...
##  $ areaconst   : num [1:8322] 70 120 220 280 90 87 52 137 150 380 ...
##  $ parqueaderos: num [1:8322] 1 1 2 3 1 1 2 2 2 2 ...
##  $ banios      : num [1:8322] 3 2 2 5 2 3 2 3 4 3 ...
##  $ habitaciones: num [1:8322] 6 3 4 3 3 3 3 4 6 3 ...
##  $ tipo        : chr [1:8322] "Casa" "Casa" "Casa" "Casa" ...
##  $ barrio      : chr [1:8322] "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
##  $ longitud    : num [1:8322] -76.5 -76.5 -76.5 -76.5 -76.5 ...
##  $ latitud     : num [1:8322] 3.43 3.43 3.44 3.44 3.46 ...
##  - attr(*, "spec")=List of 3
##   ..$ cols   :List of 13
##   .. ..$ id          : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ zona        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ piso        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ estrato     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ preciom     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ areaconst   : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ parqueaderos: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ banios      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ habitaciones: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ tipo        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ barrio      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ longitud    : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ latitud     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   ..$ default: list()
##   .. ..- attr(*, "class")= chr [1:2] "collector_guess" "collector"
##   ..$ delim  : chr ";"
##   ..- attr(*, "class")= chr "col_spec"
##  - attr(*, "problems")=<pointer: (nil)>
summary(vivienda)
##        id              zona             piso         estrato     
##  Min.   :   1   Length   :8322   Length   :8322   Min.   :3.000  
##  1st Qu.:2080   N.unique :   5   N.unique :  12   1st Qu.:4.000  
##  Median :4160   N.blank  :   0   N.blank  :   0   Median :5.000  
##  Mean   :4160   Min.nchar:   8   Min.nchar:   2   Mean   :4.634  
##  3rd Qu.:6240   Max.nchar:  12   Max.nchar:   2   3rd Qu.:5.000  
##  Max.   :8319   NAs      :   3   NAs      :2638   Max.   :6.000  
##  NAs    :3                                        NAs    :3      
##     preciom         areaconst       parqueaderos        banios      
##  Min.   :  58.0   Min.   :  30.0   Min.   : 1.000   Min.   : 0.000  
##  1st Qu.: 220.0   1st Qu.:  80.0   1st Qu.: 1.000   1st Qu.: 2.000  
##  Median : 330.0   Median : 123.0   Median : 2.000   Median : 3.000  
##  Mean   : 433.9   Mean   : 174.9   Mean   : 1.835   Mean   : 3.111  
##  3rd Qu.: 540.0   3rd Qu.: 229.0   3rd Qu.: 2.000   3rd Qu.: 4.000  
##  Max.   :1999.0   Max.   :1745.0   Max.   :10.000   Max.   :10.000  
##  NAs    :2        NAs    :3        NAs    :1605     NAs    :3       
##   habitaciones           tipo            barrio        longitud     
##  Min.   : 0.000   Length   :8322   Length   :8322   Min.   :-76.59  
##  1st Qu.: 3.000   N.unique :   2   N.unique : 436   1st Qu.:-76.54  
##  Median : 3.000   N.blank  :   0   N.blank  :   0   Median :-76.53  
##  Mean   : 3.605   Min.nchar:   4   Min.nchar:   4   Mean   :-76.53  
##  3rd Qu.: 4.000   Max.nchar:  11   Max.nchar:  29   3rd Qu.:-76.52  
##  Max.   :10.000   NAs      :   3   NAs      :   3   Max.   :-76.46  
##  NAs    :3                                          NAs    :3       
##     latitud     
##  Min.   :3.333  
##  1st Qu.:3.381  
##  Median :3.416  
##  Mean   :3.418  
##  3rd Qu.:3.452  
##  Max.   :3.498  
##  NAs    :3
# Se excluye id porque solo identifica el anuncio. Las variables de texto se
# normalizan y el piso sin registro se conserva como categoría explícita.
vivienda_limpia <- vivienda
vivienda_limpia$id <- NULL
vivienda_limpia$piso <- ifelse(is.na(vivienda_limpia$piso), "Sin dato",
                               vivienda_limpia$piso)

categoricas <- c("zona", "piso", "tipo", "barrio")
vivienda_limpia[categoricas] <- lapply(vivienda_limpia[categoricas], factor)


# Longitud y latitud se excluyen de las variables numéricas empleadas en el
# ACP y en el análisis de conglomerados: son coordenadas geográficas, no
# atributos del inmueble, y si se estandarizan junto con precio, área, etc.
# los componentes y los clusters terminarían explicando en parte la
# ubicación en lugar del perfil de la propiedad. Se conservan únicamente
# para el mapa de la sección 4, donde se valida si los segmentos ya
# formados se concentran espacialmente.
vivienda_limpia$preciom2 <- vivienda_limpia$preciom / vivienda_limpia$areaconst

numericas <- c("estrato", "preciom", "areaconst", "parqueaderos", "banios",
               "habitaciones", "preciom2")

# Conteo y proporción de datos faltantes por variable.
faltantes <- data.frame(
  variable = names(vivienda_limpia),
  n_faltantes = colSums(is.na(vivienda_limpia)),
  porcentaje = round(100 * colSums(is.na(vivienda_limpia)) / nrow(vivienda_limpia), 2)
)
faltantes
# Para los análisis multivariados se usan registros completos en las variables
# requeridas. La decisión se reporta para mantener trazabilidad.
datos_num <- vivienda_limpia[complete.cases(vivienda_limpia[numericas]), numericas]
cat("Registros disponibles para ACP y conglomerados:", nrow(datos_num), "de", nrow(vivienda_limpia))
## Registros disponibles para ACP y conglomerados: 6717 de 8322
par(mfrow = c(2, 4), mar = c(4, 4, 2, 1))
for (v in c("preciom", "areaconst", "preciom2", "estrato", "parqueaderos", "banios", "habitaciones")) {
  hist(vivienda_limpia[[v]], main = paste("Distribución de", v), xlab = v,
       col = "#5B9BD5", border = "white")
}
par(mfrow = c(1, 1))

Criterio de calidad. El precio y el área pueden presentar valores extremos propios del mercado de lujo. No se eliminan automáticamente: se revisan visualmente y se conserva su información, pues esos inmuebles pueden constituir un segmento estratégico. La estandarización posterior evita que una variable medida en una escala mayor domine el análisis.

3 2. Análisis de Componentes Principales

El ACP se realiza sobre las variables numéricas estandarizadas. Esto permite comparar las propiedades medidas en escalas distintas, como precio, metros cuadrados y número de baños.

acp <- prcomp(datos_num, center = TRUE, scale. = TRUE)
var_exp <- 100 * acp$sdev^2 / sum(acp$sdev^2)
var_acum <- cumsum(var_exp)

tabla_acp <- data.frame(
  componente = paste0("CP", seq_along(var_exp)),
  varianza_explicada = round(var_exp, 2),
  varianza_acumulada = round(var_acum, 2)
)
knitr::kable(tabla_acp, caption = "Varianza explicada por los componentes principales")
Varianza explicada por los componentes principales
componente varianza_explicada varianza_acumulada
CP1 49.84 49.84
CP2 26.84 76.68
CP3 8.14 84.82
CP4 6.11 90.92
CP5 4.70 95.62
CP6 3.34 98.96
CP7 1.04 100.00
plot(var_exp, type = "b", pch = 19, col = "#1F4E78",
     xlab = "Componente principal", ylab = "Varianza explicada (%)",
     main = "Gráfico de sedimentación")
abline(h = 100 / length(var_exp), lty = 2, col = "#C00000")

cargas <- round(acp$rotation[, 1:2], 3)
knitr::kable(cargas, caption = "Cargas de las variables en los dos primeros componentes")
Cargas de las variables en los dos primeros componentes
PC1 PC2
estrato 0.305 -0.472
preciom 0.475 -0.205
areaconst 0.444 0.258
parqueaderos 0.429 -0.069
banios 0.463 0.112
habitaciones 0.289 0.468
preciom2 0.047 -0.657
biplot(acp, choices = 1:2, cex = 0.55,
       main = "Propiedades y variables en los dos primeros componentes")

Interpretación. Para interpretar cada componente se revisan las cargas con mayor valor absoluto. Variables que apuntan en la misma dirección tienden a crecer conjuntamente; variables en direcciones opuestas representan perfiles contrastantes. El componente asociado a precio, área, baños y parqueaderos puede interpretarse como una dimensión de tamaño y nivel de oferta, siempre que las cargas calculadas lo confirmen.

4 3. Análisis de conglomerados

Se aplica k-means a las variables numéricas estandarizadas. El número de conglomerados se selecciona comparando la anchura promedio de silueta para valores de k entre 2 y 8. Esta decisión evita imponer una segmentación arbitraria.

datos_esc <- scale(datos_num)
k_candidatos <- 2:8
set.seed(2026)
sil_promedio <- sapply(k_candidatos, function(k) {
  km <- kmeans(datos_esc, centers = k, nstart = 50, iter.max = 50)
  mean(silhouette(km$cluster, dist(datos_esc))[, "sil_width"])
})

seleccion_k <- data.frame(k = k_candidatos,
                           silueta_promedio = round(sil_promedio, 3))
knitr::kable(seleccion_k, caption = "Selección del número de conglomerados")
Selección del número de conglomerados
k silueta_promedio
2 0.374
3 0.345
4 0.310
5 0.248
6 0.254
7 0.243
8 0.246
k_optimo <- k_candidatos[which.max(sil_promedio)]
cat("Número de conglomerados seleccionado:", k_optimo)
## Número de conglomerados seleccionado: 2
plot(k_candidatos, sil_promedio, type = "b", pch = 19, col = "#70AD47",
     xlab = "Número de conglomerados", ylab = "Silueta promedio",
     main = "Criterio de silueta para seleccionar k")
abline(v = k_optimo, lty = 2, col = "#C00000")

set.seed(2026)
modelo_km <- kmeans(datos_esc, centers = k_optimo, nstart = 100, iter.max = 50)

perfil_conglomerados <- aggregate(datos_num,
                                  by = list(conglomerado = modelo_km$cluster),
                                  FUN = mean)
perfil_conglomerados <- perfil_conglomerados[order(perfil_conglomerados$preciom), ]
knitr::kable(round(perfil_conglomerados, 2),
             caption = "Perfil promedio de los conglomerados")
Perfil promedio de los conglomerados
conglomerado estrato preciom areaconst parqueaderos banios habitaciones preciom2
1 4.53 302.19 119.33 1.37 2.58 3.20 2.77
2 5.50 841.78 319.41 2.88 4.77 4.52 3.06
plot(acp$x[, 1], acp$x[, 2], col = modelo_km$cluster, pch = 19,
     xlab = "Componente principal 1", ylab = "Componente principal 2",
     main = "Conglomerados proyectados en el plano principal")
legend("topright", legend = paste("Grupo", sort(unique(modelo_km$cluster))),
       col = sort(unique(modelo_km$cluster)), pch = 19, bty = "n")

Interpretación. Los perfiles promedio permiten describir cada segmento por su precio, área, estrato, número de parqueaderos, baños y habitaciones. Se recomienda asignar una etiqueta de negocio después de revisar la tabla: por ejemplo, oferta compacta, vivienda familiar, segmento medio consolidado o propiedad de alta gama. Las etiquetas deben corresponder a los promedios observados y no a supuestos previos.

5 4. Distribución espacial de los conglomerados

indices_completos <- which(complete.cases(vivienda_limpia[numericas]))
datos_mapa <- vivienda_limpia[indices_completos, ]
datos_mapa$conglomerado <- factor(modelo_km$cluster)

paleta_cluster <- colorFactor(
  palette = c("#1F4E78", "#C00000", "#2CA02C", "#9467BD",
              "#FF7F0E", "#8C564B", "#E377C2", "#7F7F7F"),
  domain  = levels(datos_mapa$conglomerado)
)

leaflet(datos_mapa) %>%
  addProviderTiles(providers$OpenStreetMap) %>%
  fitBounds(
    lng1 = min(datos_mapa$longitud), lat1 = min(datos_mapa$latitud),
    lng2 = max(datos_mapa$longitud), lat2 = max(datos_mapa$latitud)
  ) %>%
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud,
    color = ~paleta_cluster(conglomerado),
    radius = 4, stroke = FALSE, fillOpacity = 0.75,
    popup = ~paste0(
      "<b>Conglomerado:</b> ", conglomerado, "<br>",
      "<b>Zona:</b> ", zona, "<br>",
      "<b>Barrio:</b> ", barrio, "<br>",
      "<b>Tipo:</b> ", tipo, "<br>",
      "<b>Precio:</b> $", format(preciom, big.mark = ","), " millones<br>",
      "<b>Área construida:</b> ", areaconst, " m²"
    )
  ) %>%
  addLegend("bottomright", pal = paleta_cluster, values = ~conglomerado,
            title = "Conglomerado", opacity = 1)

La visualización espacial permite identificar si los grupos se concentran en zonas específicas o si compiten en las mismas áreas. Una concentración espacial puede orientar la captación de inmuebles, la promoción segmentada y la estimación de precios de referencia.

6 5. Análisis de correspondencia

El análisis de correspondencia se aplica a tablas de contingencia entre las variables categóricas. Se presentan dos relaciones: tipo de vivienda frente a zona y tipo de vivienda frente a los barrios con mayor número de anuncios.

tabla_tipo_zona <- table(vivienda_limpia$tipo, vivienda_limpia$zona)
chisq_tipo_zona <- chisq.test(tabla_tipo_zona)
knitr::kable(as.data.frame.matrix(tabla_tipo_zona),
             caption = "Frecuencia de tipo de vivienda por zona")
Frecuencia de tipo de vivienda por zona
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 24 1198 1029 62 2787
Casa 100 722 169 289 1939
chisq_tipo_zona
## 
##  Pearson's Chi-squared test
## 
## data:  tabla_tipo_zona
## X-squared = 690.93, df = 4, p-value < 2.2e-16
# La causa real del error "invalid table specification" no es nf: table()
# devuelve un objeto de clase "table", y MASS::corresp no tiene un método
# para esa clase (solo para matrix, data.frame, xtabs, etc.), así que cae en
# corresp.default(), que simplemente detiene la ejecución. unclass() quita
# esa clase y deja una matriz normal, que sí es reconocida.
# Además, nf debe ser <= min(dim(tabla)) - 1: con "tipo" de solo 2 categorías
# el máximo nf válido es 1.
nf_zona <- min(2, min(dim(tabla_tipo_zona)) - 1)
ca_tipo_zona <- MASS::corresp(unclass(tabla_tipo_zona), nf = nf_zona)
plot(ca_tipo_zona, main = "Correspondencia: tipo de vivienda y zona")

top_barrios <- names(sort(table(vivienda_limpia$barrio), decreasing = TRUE))[1:15]
tabla_tipo_barrio <- table(vivienda_limpia$tipo,
                           factor(vivienda_limpia$barrio, levels = top_barrios))
nf_barrio <- min(2, min(dim(tabla_tipo_barrio)) - 1)
ca_tipo_barrio <- MASS::corresp(unclass(tabla_tipo_barrio), nf = nf_barrio)
plot(ca_tipo_barrio,
     main = "Correspondencia: tipo de vivienda y 15 barrios con mayor oferta")

Interpretación. En los mapas de correspondencia, las categorías cercanas indican una asociación relativa mayor que la esperada por independencia. El resultado debe leerse junto con la prueba chi-cuadrado: una asociación estadísticamente significativa respalda que la distribución de tipos de vivienda no es igual en todas las zonas o barrios.

7 6. Conclusiones y recomendaciones

7.1 Conclusiones

  1. El ACP resume las variables numéricas en dimensiones interpretables y reduce la complejidad del análisis sin perder la mayor parte de la información relevante.
  2. Los conglomerados revelan segmentos de oferta con combinaciones distintas de precio, tamaño, estrato y equipamiento. Su perfil debe ser la base de una estrategia comercial diferenciada.
  3. La representación espacial permite verificar si los segmentos están concentrados territorialmente, información clave para captar inventario y definir precios de referencia locales.
  4. El análisis de correspondencia muestra qué tipos de vivienda se relacionan con zonas y barrios particulares; esto aporta evidencia para focalizar publicidad y recomendaciones a compradores.

7.2 Recomendaciones para la empresa inmobiliaria

  1. Valorar por segmento y ubicación: usar los perfiles de conglomerados como punto de partida para comparar una propiedad nueva con inmuebles similares, en lugar de usar un promedio único de mercado.
  2. Diseñar campañas diferenciadas: orientar la comunicación según los segmentos identificados, destacando atributos coherentes con cada perfil de cliente e inmueble.
  3. Priorizar captación geográfica: concentrar la búsqueda de nuevos inmuebles en zonas donde se observe una oferta consistente y rentable para el segmento objetivo.
  4. Actualizar el análisis periódicamente: el mercado inmobiliario cambia; se recomienda repetir el ejercicio al incorporar nuevos anuncios y monitorear cambios en precio, área y composición de la oferta.

8 Reproducibilidad

La fuente de datos es el conjunto vivienda del paquete paqueteMODELOS, elaborado a partir de información de OLX mediante web scraping y dispuesto para fines académicos. El análisis utiliza una semilla fija (2026) para que la segmentación sea reproducible. Antes de publicar en RPubs se debe tejer el archivo y revisar que las tablas, gráficos y conclusiones se generen correctamente con la versión vigente de la base.

8.1 Anexo: información de sesión

sessionInfo()
## R version 4.6.1 (2026-06-24 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] leaflet_2.2.3        cluster_2.1.8.3      paqueteMODELOS_0.1.0
##  [4] summarytools_1.1.5   knitr_1.51           gridExtra_2.3.1     
##  [7] GGally_2.4.0         ggplot2_4.0.3        broom_1.0.13        
## [10] boot_1.3-32         
## 
## loaded via a namespace (and not attached):
##  [1] sass_0.4.10             generics_0.1.4          tidyr_1.3.2            
##  [4] tcltk_4.6.1             stringi_1.8.9           digest_0.6.39          
##  [7] magrittr_2.0.5          evaluate_1.0.5          grid_4.6.1             
## [10] timechange_0.4.0        RColorBrewer_1.1-3      fastmap_1.2.0          
## [13] plyr_1.8.9              jsonlite_2.0.0          rapportools_1.2        
## [16] backports_1.5.1         purrr_1.2.2             pander_0.6.6           
## [19] crosstalk_1.2.2         scales_1.4.0            jquerylib_0.1.4        
## [22] cli_3.6.6               rlang_1.3.0             base64enc_0.1-6        
## [25] withr_3.0.3             cachem_1.1.0            yaml_2.3.12            
## [28] tools_4.6.1             reshape2_1.4.5          checkmate_2.3.4        
## [31] dplyr_1.2.1             ggstats_0.13.0          vctrs_0.7.3            
## [34] R6_2.6.1                matrixStats_1.5.0       lifecycle_1.0.5        
## [37] lubridate_1.9.5         magick_2.9.1            stringr_1.6.0          
## [40] leaflet.providers_3.0.0 htmlwidgets_1.6.4       MASS_7.3-65            
## [43] pkgconfig_2.0.3         pillar_1.11.1           bslib_0.12.0           
## [46] gtable_0.3.6            glue_1.8.1              Rcpp_1.1.2             
## [49] xfun_0.60               tibble_3.3.1            tidyselect_1.2.1       
## [52] farver_2.1.2            htmltools_0.5.9         rmarkdown_2.31         
## [55] compiler_4.6.1          S7_0.2.2