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:
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?
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.
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")
| 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")
| 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.
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")
| 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")
| 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.
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.
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")
| 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.
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.
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