El mercado de vivienda urbana concentra información heterogénea —precios, áreas construidas, ubicación geográfica, atributos físicos y clasificación socioeconómica— cuya lectura conjunta rara vez es evidente a partir de estadísticas univariadas. Para una empresa inmobiliaria que participa activamente en la compra, venta y valoración de inmuebles, la capacidad de sintetizar esa complejidad en patrones interpretables constituye una fuente directa de ventaja competitiva: permite anticipar segmentos de demanda, calibrar precios de referencia por zona y tipo de inmueble, y focalizar el esfuerzo comercial en los nichos de mayor rentabilidad relativa.
Este informe desarrolla un análisis estadístico multivariado sobre
una base de oferta inmobiliaria urbana, obtenida mediante web
scraping del portal OLX y dispuesta en el paquete
paqueteMODELOS (objeto vivienda); el tamaño
exacto de la muestra y su composición se detallan en la sección 3. Se
abordan tres técnicas de reducción y clasificación de la información
—Análisis de Componentes Principales (ACP), Análisis de Conglomerados y
Análisis de Correspondencias (simple y múltiple)— complementadas con
visualización geoespacial, con el fin de construir una lectura integral
del mercado que soporte decisiones estratégicas de inversión y
valoración.
Objetivo general. Caracterizar la estructura multidimensional de la oferta inmobiliaria urbana mediante técnicas de análisis estadístico multivariado, con fines de apoyo a la toma de decisiones comerciales y de inversión.
Objetivos específicos.
El documento se organiza en un marco metodológico (sección 2), la preparación del entorno y los datos (sección 3), un análisis exploratorio (sección 4), los tres análisis multivariados solicitados —ACP, conglomerados y correspondencias— (secciones 5 a 7), una síntesis integradora (sección 8) y, finalmente, las conclusiones y recomendaciones estratégicas (sección 9), las referencias (sección 10) y un anexo de reproducibilidad (sección 11).
Los datos provienen del objeto vivienda del paquete
paqueteMODELOS (repositorio
centromagis/paqueteMODELOS), construido mediante web
scraping de anuncios de venta de vivienda publicados en OLX. Cada
fila representa un inmueble ofertado, con las siguientes variables:
| Variable | Tipo | Descripción |
|---|---|---|
id |
Identificador | Código único del anuncio (no se usa como variable analítica). |
zona |
Categórica | Zona urbana en la que se ubica el inmueble. |
piso |
Categórica | Piso del inmueble (aplica principalmente a apartamentos;
NA en casas). |
estrato |
Ordinal/Numérica | Estrato socioeconómico (1 a 6, sistema colombiano de clasificación predial). |
preciom |
Cuantitativa | Precio de venta, en millones de pesos. |
areaconst |
Cuantitativa | Área construida, en metros cuadrados. |
parqueaderos |
Cuantitativa | Número de parqueaderos. |
banios |
Cuantitativa | Número de baños. |
habitaciones |
Cuantitativa | Número de habitaciones. |
tipo |
Categórica | Tipo de inmueble (casa, apartamento, etc.). |
barrio |
Categórica | Barrio de ubicación. |
longitud, latitud |
Numérica | Coordenadas geográficas del inmueble. |
Análisis de Componentes Principales (ACP). Técnica de reducción de dimensionalidad que, a partir de la matriz de correlaciones de \(p\) variables cuantitativas estandarizadas, obtiene su descomposición en valores y vectores propios (\(R = V\Lambda V^{T}\)) para construir \(p\) nuevas variables no correlacionadas (componentes), ordenadas por la varianza que explican. Se emplea aquí para sintetizar el tamaño/calidad del inmueble a partir de sus atributos físicos y de precio (Jolliffe, 2002; Husson, Lê & Pagès, 2017).
Análisis de Conglomerados. Conjunto de técnicas de clasificación no supervisada que agrupan observaciones minimizando la variabilidad intra-grupo y maximizando la variabilidad entre grupos. Se utiliza la metodología HCPC (Hierarchical Clustering on Principal Components), que aplica clasificación jerárquica de Ward sobre las coordenadas factoriales del ACP —evitando así el ruido de las dimensiones de baja varianza— y valida el número de grupos mediante el criterio de pérdida de inercia y con un algoritmo particional (k-means) como contraste (Ward, 1963; Kaufman & Rousseeuw, 1990; Husson et al., 2017).
Análisis de Correspondencias (simple y múltiple). Técnica análoga al ACP pero aplicada a tablas de contingencia entre variables categóricas: descompone la distancia ji-cuadrado entre perfiles fila y columna para representar en un plano de baja dimensión las asociaciones entre categorías. El Análisis de Correspondencias Simple (CA) se usa para dos variables (tipo × zona) y el Análisis de Correspondencias Múltiples (MCA) se extiende a tres o más variables categóricas (tipo, zona y barrio), tal como lo requiere el reto 3 (Greenacre, 2007; Husson et al., 2017).
# Asegura un mirror de CRAN configurado (algunas instalaciones nuevas de
# R/RStudio no traen uno por defecto y install.packages() falla con
# "trying to use CRAN without setting a mirror"). Se fija de forma
# incondicional: no tiene efectos secundarios si ya había uno configurado.
options(repos = c(CRAN = "https://cloud.r-project.org"))
paquetes_cran <- c(
"tidyverse", "FactoMineR", "factoextra", "cluster", "psych",
"corrplot", "leaflet", "DT", "kableExtra", "scales",
"RColorBrewer", "remotes", "e1071", "gridExtra"
)
faltantes <- paquetes_cran[!paquetes_cran %in% installed.packages()[, "Package"]]
# dependencies = c("Depends","Imports","LinkingTo") evita instalar de forma
# recursiva las dependencias "Suggests" (opcionales) de cada paquete, que
# pueden ser muy pesadas e innecesarias (p. ej. Factoshiny, FactoInvestigate,
# missMDA, summarytools -- este último requiere tcltk/X11 y suele fallar en
# Mac sin XQuartz instalado).
if (length(faltantes) > 0) {
install.packages(faltantes, dependencies = c("Depends", "Imports", "LinkingTo"))
}
paquete_modelos_instalado <- "paqueteMODELOS" %in% rownames(installed.packages())
# Nota: se usa installed.packages() en lugar de requireNamespace() a
# propósito -- requireNamespace() SÍ carga el paquete si ya está instalado
# (disparando de nuevo el problema de tcltk descrito abajo), mientras que
# installed.packages() solo consulta metadatos, sin cargar nada.
if (!paquete_modelos_instalado) {
# dependencies = FALSE: paqueteMODELOS solo se necesita para su dataset
# "vivienda"; no hace falta instalar sus dependencias opcionales.
#
# INSTALL_opts = "--no-test-load": paqueteMODELOS declara summarytools
# como dependencia, y summarytools importa tcltk, que en macOS requiere
# tener XQuartz (X11) instalado. Si no está, R CMD INSTALL falla en su
# paso final de verificación ("testing if installed package can be
# loaded...") y DESINSTALA todo el paquete recién compilado -- aunque
# los datos ya se habían escrito correctamente a disco. Con esta opción
# se omite esa verificación final, así el paquete (y su dataset) quedan
# instalados de todas formas. Como más abajo solo leemos el dataset con
# data(..., package=...) -- sin hacer library(paqueteMODELOS) -- nunca
# se llega a cargar su espacio de nombres ni, por lo tanto, tcltk.
#
# Alternativa equivalente: devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
remotes::install_github("centromagis/paqueteMODELOS", force = TRUE,
dependencies = FALSE, upgrade = "never",
INSTALL_opts = c("--no-test-load"))
}
# Nota: "paqueteMODELOS" se deja fuera de este lapply a propósito. Solo
# necesitamos su dataset "vivienda" (chunk siguiente), al que se accede con
# data(..., package = "paqueteMODELOS") sin necesidad de adjuntar el paquete
# completo con library() -- evitando así depender de summarytools/tcltk.
invisible(lapply(paquetes_cran, library, character.only = TRUE))# El objeto se llama "vivienda" en el paquete; se incluye una alternativa
# defensiva por si la versión instalada expone el nombre con la grafía
# "vivenda" (variante observada en algunas versiones del paquete).
vivienda <- tryCatch(
{ data("vivienda", package = "paqueteMODELOS"); vivienda },
error = function(e) { data("vivenda", package = "paqueteMODELOS"); vivenda }
)
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")=
## .. cols(
## .. id = col_double(),
## .. zona = col_character(),
## .. piso = col_character(),
## .. estrato = col_double(),
## .. preciom = col_double(),
## .. areaconst = col_double(),
## .. parqueaderos = col_double(),
## .. banios = col_double(),
## .. habitaciones = col_double(),
## .. tipo = col_character(),
## .. barrio = col_character(),
## .. longitud = col_double(),
## .. latitud = col_double()
## .. )
## - attr(*, "problems")=<externalptr>
La base contiene 8322 anuncios de vivienda con 13 variables registradas.
vivienda <- vivienda %>%
mutate(
id = as.character(id),
zona = factor(str_trim(zona)),
tipo = factor(str_trim(tipo)),
barrio = str_trim(barrio),
estrato_cat = factor(estrato, levels = sort(unique(estrato)), ordered = TRUE),
piso_num = suppressWarnings(as.numeric(piso)),
log_preciom = log(preciom),
precio_m2 = preciom * 1e6 / areaconst # pesos por m2, para lectura de negocio
)
# Agregación de "barrio" a las categorías más frecuentes + "Otros",
# necesaria porque el MCA pierde interpretabilidad con decenas de
# niveles poco poblados (regla práctica: se conservan los barrios que
# concentran individualmente al menos ~1% de las observaciones).
tabla_barrios <- vivienda %>% count(barrio, sort = TRUE) %>%
mutate(pct = n / sum(n))
umbral_barrio <- 0.01
barrios_top <- tabla_barrios %>% filter(pct >= umbral_barrio) %>% pull(barrio)
vivienda <- vivienda %>%
mutate(barrio_agg = if_else(barrio %in% barrios_top, barrio, "Otros"),
barrio_agg = fct_infreq(factor(barrio_agg)))
n_barrios_agg <- n_distinct(vivienda$barrio_agg)Tras la agregación, barrio quedó representado por
18 categorías (los barrios con al menos el 1% de la
oferta, más la categoría residual “Otros”), lo que preserva la
interpretabilidad del análisis de correspondencias sin perder
representatividad.
La variable piso presenta valores faltantes de tipo
estructural (no aleatorio): únicamente aplica a inmuebles verticales,
por lo que las casas quedan naturalmente codificadas como
NA. Por ello no se imputa ni se excluye del conjunto:
simplemente no participa como variable activa en los análisis
multivariados, y se describe de forma independiente para los inmuebles
tipo apartamento.
na_tabla <- vivienda %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_na") %>%
mutate(pct_na = scales::percent(n_na / n_original, accuracy = 0.1)) %>%
arrange(desc(n_na))
na_tabla %>%
kableExtra::kbl(caption = "Valores faltantes por variable") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| variable | n_na | pct_na |
|---|---|---|
| piso | 2638 | 31.7% |
| piso_num | 2638 | 31.7% |
| parqueaderos | 1605 | 19.3% |
| id | 3 | 0.0% |
| zona | 3 | 0.0% |
| estrato | 3 | 0.0% |
| areaconst | 3 | 0.0% |
| banios | 3 | 0.0% |
| habitaciones | 3 | 0.0% |
| tipo | 3 | 0.0% |
| barrio | 3 | 0.0% |
| longitud | 3 | 0.0% |
| latitud | 3 | 0.0% |
| estrato_cat | 3 | 0.0% |
| precio_m2 | 3 | 0.0% |
| preciom | 2 | 0.0% |
| log_preciom | 2 | 0.0% |
| barrio_agg | 0 | 0.0% |
vars_cuanti <- c("preciom", "areaconst", "parqueaderos", "banios", "habitaciones")
desc_cuanti <- vivienda %>%
select(all_of(vars_cuanti)) %>%
summarise(across(everything(),
list(min = ~min(., na.rm = TRUE), media = ~mean(., na.rm = TRUE),
mediana = ~median(., na.rm = TRUE), ds = ~sd(., na.rm = TRUE),
max = ~max(., na.rm = TRUE),
asimetria = ~e1071::skewness(., na.rm = TRUE)),
.names = "{.col}__{.fn}")) %>%
pivot_longer(everything(), names_sep = "__", names_to = c("variable", "estadistico")) %>%
pivot_wider(names_from = estadistico, values_from = value)
desc_cuanti %>%
kableExtra::kbl(digits = 2, caption = "Estadística descriptiva de variables cuantitativas") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| variable | min | media | mediana | ds | max | asimetria |
|---|---|---|---|---|---|---|
| preciom | 58 | 433.89 | 330 | 328.65 | 1999 | 1.85 |
| areaconst | 30 | 174.93 | 123 | 142.96 | 1745 | 2.69 |
| parqueaderos | 1 | 1.84 | 2 | 1.12 | 10 | 2.33 |
| banios | 0 | 3.11 | 3 | 1.43 | 10 | 0.93 |
| habitaciones | 0 | 3.61 | 3 | 1.46 | 10 | 1.63 |
El precio (preciom) presenta una asimetría de 1.85,
marcadamente positiva: la distribución tiene una cola derecha larga,
propia de mercados donde un segmento reducido de inmuebles de alto valor
eleva la media por encima de la mediana. Esta característica se tiene en
cuenta más adelante al decidir el tratamiento de valores extremos para
el ACP y el análisis de conglomerados.
p1 <- ggplot(vivienda, aes(x = preciom)) +
geom_histogram(bins = 40, fill = "#2c7fb8", color = "white") +
labs(x = "Precio (millones COP)", y = "Frecuencia",
title = "Distribución del precio de oferta") +
theme_minimal()
p2 <- ggplot(vivienda, aes(x = log_preciom)) +
geom_histogram(bins = 40, fill = "#41ab5d", color = "white") +
labs(x = "log(Precio)", y = "Frecuencia",
title = "Distribución del precio (escala logarítmica)") +
theme_minimal()
gridExtra::grid.arrange(p1, p2, ncol = 2)Distribución del precio de oferta
mat_cor <- vivienda %>% select(all_of(vars_cuanti)) %>%
cor(use = "pairwise.complete.obs")
corrplot::corrplot(mat_cor, method = "color", type = "upper",
addCoef.col = "black", tl.col = "black", tl.srt = 45,
number.cex = 0.8, diag = FALSE,
title = "Matriz de correlaciones — variables del inmueble",
mar = c(0, 0, 2, 0))El precio y el área construida muestran una correlación de 0.69, consistente con el patrón esperado en mercados inmobiliarios: a mayor área, mayor precio, aunque con dispersión suficiente para que otras variables (zona, estrato, tipo) aporten información adicional no redundante.
b1 <- ggplot(vivienda, aes(x = fct_infreq(zona))) +
geom_bar(fill = "#2c7fb8") + coord_flip() +
labs(x = NULL, y = "N° de inmuebles", title = "Oferta por zona") + theme_minimal()
b2 <- ggplot(vivienda, aes(x = fct_infreq(tipo))) +
geom_bar(fill = "#41ab5d") + coord_flip() +
labs(x = NULL, y = "N° de inmuebles", title = "Oferta por tipo de vivienda") + theme_minimal()
b3 <- ggplot(vivienda, aes(x = estrato_cat)) +
geom_bar(fill = "#fd8d3c") +
labs(x = "Estrato", y = "N° de inmuebles", title = "Oferta por estrato") + theme_minimal()
b4 <- ggplot(vivienda, aes(x = zona, y = preciom, fill = zona)) +
geom_boxplot(show.legend = FALSE) + coord_flip() +
labs(x = NULL, y = "Precio (millones COP)", title = "Precio por zona") + theme_minimal()
gridExtra::grid.arrange(b1, b2, b3, b4, ncol = 2)q_bajo <- quantile(vivienda$preciom, 0.01, na.rm = TRUE)
q_alto <- quantile(vivienda$preciom, 0.99, na.rm = TRUE)
vivienda_activa <- vivienda %>%
filter(preciom >= q_bajo, preciom <= q_alto) %>%
filter(if_all(all_of(vars_cuanti), ~ !is.na(.)))
n_excluidos <- n_original - nrow(vivienda_activa)Para el ACP y el análisis de conglomerados se construye un
subconjunto analítico (vivienda_activa) que excluye el 1%
inferior y superior de la distribución de precio —outliers extremos que,
bajo distancia euclídea, distorsionarían desproporcionadamente los
primeros componentes— así como los registros con valores faltantes en
las variables cuantitativas activas. Se excluyeron 1706
observaciones (20.5% de la base), que se conservan íntegramente en los
análisis descriptivos, en el análisis de correspondencias y en los mapas
exploratorios; el subconjunto analítico resultante conserva
6616 inmuebles.
mat_cor_activa <- vivienda_activa %>% select(all_of(vars_cuanti)) %>% cor()
kmo_res <- psych::KMO(mat_cor_activa)
bartlett_res <- psych::cortest.bartlett(mat_cor_activa, n = nrow(vivienda_activa))El índice KMO global es de 0.76 (adecuación muestral buena a excelente según los umbrales convencionales de Kaiser), y el test de esfericidad de Bartlett resulta significativo (p < 0.05), lo que rechaza la hipótesis de que la matriz de correlaciones es la identidad y respalda la pertinencia de aplicar ACP.
Se incluyen como variables activas las cinco variables cuantitativas
del inmueble (preciom, areaconst,
parqueaderos, banios,
habitaciones); estrato se incorpora como
variable cuantitativa suplementaria (no participa en el cálculo de los
ejes, pero se proyecta sobre ellos para apoyar la interpretación), y
zona y tipo como variables categóricas
suplementarias.
eig_tabla <- as.data.frame(res.pca$eig)
colnames(eig_tabla) <- c("Valor propio", "% Varianza", "% Varianza acumulada")
eig_tabla %>%
kableExtra::kbl(digits = 2, caption = "Valores propios y varianza explicada") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Valor propio | % Varianza | % Varianza acumulada | |
|---|---|---|---|
| comp 1 | 3.23 | 64.66 | 64.66 |
| comp 2 | 0.86 | 17.13 | 81.79 |
| comp 3 | 0.38 | 7.66 | 89.45 |
| comp 4 | 0.34 | 6.71 | 96.16 |
| comp 5 | 0.19 | 3.84 | 100.00 |
factoextra::fviz_eig(res.pca, addlabels = TRUE, barfill = "#2c7fb8", barcolor = "#2c7fb8",
ylim = c(0, max(eig_tabla$`% Varianza`) * 1.15)) +
labs(title = "Varianza explicada por componente")Bajo el criterio de Kaiser (valores propios superiores a 1) se retienen 1 componentes. Las dos primeras dimensiones explican en conjunto 81.8% de la varianza total, proporción que se considera suficiente para una lectura bidimensional del fenómeno y que se usa como base para el análisis de conglomerados en la siguiente sección.
factoextra::fviz_pca_var(res.pca, col.var = "contrib",
gradient.cols = c("#2166ac", "#f7f7f7", "#b2182b"),
repel = TRUE) +
labs(title = "Círculo de correlaciones — contribución de las variables")contrib_tabla <- as.data.frame(res.pca$var$contrib) %>%
rownames_to_column("variable") %>%
select(variable, Dim.1, Dim.2)
contrib_tabla %>%
kableExtra::kbl(digits = 1, caption = "Contribución (%) de cada variable a Dim.1 y Dim.2") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| variable | Dim.1 | Dim.2 |
|---|---|---|
| preciom | 21.8 | 17.2 |
| areaconst | 23.1 | 0.5 |
| parqueaderos | 18.5 | 19.6 |
| banios | 23.8 | 2.3 |
| habitaciones | 12.8 | 60.5 |
top_dim1 <- contrib_tabla$variable[which.max(contrib_tabla$Dim.1)]
top_dim2 <- contrib_tabla$variable[which.max(contrib_tabla$Dim.2)]La variable con mayor contribución a la Dimensión 1 es banios (23.8%), seguida por las demás variables de tamaño físico y precio, todas con cargas de igual signo: este primer eje se interpreta como un factor de “tamaño y valor del inmueble”, que ordena las viviendas de menor a mayor superficie, número de espacios y precio de oferta. La Dimensión 2 está dominada por habitaciones (60.5%) y captura variación adicional no explicada por el eje de tamaño: diferencias en la composición de espacios (por ejemplo, inmuebles con proporciones atípicas de baños/habitaciones respecto a su área construida), es decir, un eje de “composición/eficiencia del espacio”.
# habillage referencia la posición de la variable en el data.frame usado en
# PCA (df_pca): 1-5 activas, 6 = estrato (quanti.sup), 7 = zona, 8 = tipo.
factoextra::fviz_pca_ind(res.pca, geom = "point", habillage = 7,
addEllipses = TRUE, ellipse.level = 0.68,
alpha.ind = 0.5, palette = "Set1") +
labs(title = "Individuos en el plano factorial, coloreados por zona")factoextra::fviz_pca_ind(res.pca, geom = "point", habillage = 8,
addEllipses = TRUE, ellipse.level = 0.68,
alpha.ind = 0.5, palette = "Dark2") +
labs(title = "Individuos en el plano factorial, coloreados por tipo de vivienda")La proyección de las variables suplementarias categóricas
(zona, tipo) y de estrato sobre
el plano factorial permite anclar la interpretación puramente
estadística de los componentes con las categorías de negocio: los
elipses de confianza por zona y tipo cuya posición se desplaza hacia la
derecha del eje 1 corresponden a los segmentos con inmuebles de mayor
tamaño y valor.
El ACP confirma que la variabilidad del mercado inmobiliario analizado responde, en su mayor parte, a un único eje dominante de tamaño/valor del inmueble, sobre el cual se diferencian las zonas y estratos. Esta síntesis en 1 componentes es la que se utiliza a continuación como insumo para la segmentación por conglomerados, evitando así que el ruido de variables poco informativas afecte la clasificación.
Se emplea la metodología HCPC (clasificación jerárquica de Ward sobre las coordenadas del ACP), que además de construir el dendrograma sugiere de forma automática el número de grupos que maximiza la ganancia relativa de inercia entre particiones sucesivas.
res.hcpc <- HCPC(res.pca, nb.clust = -1, graph = FALSE)
n_clusters <- n_distinct(res.hcpc$data.clust$clust)El criterio de HCPC sugiere 3 conglomerados como partición óptima. Como validación complementaria, se estima el coeficiente de silueta promedio para particiones de k-means alternativas sobre las mismas coordenadas factoriales:
# drop = FALSE evita que R "aplane" la matriz a un vector cuando n_kaiser
# es 1 (comportamiento por defecto de R al indexar una sola columna), lo
# que rompía fviz_nbclust() al exigir una matriz/data.frame. Además se usa
# como mínimo 2 dimensiones para que la validación por silueta tenga
# sentido geométrico, incluso si Kaiser retuvo una sola componente.
n_dim_cluster <- min(max(n_kaiser, 2), ncol(res.pca$ind$coord))
coords_pca <- res.pca$ind$coord[, 1:n_dim_cluster, drop = FALSE]
factoextra::fviz_nbclust(coords_pca, kmeans, method = "silhouette", k.max = 8) +
labs(title = "Validación del número de conglomerados (silueta promedio, k-means)")plot(res.hcpc, choice = "map", draw.tree = FALSE,
title = "Individuos coloreados por conglomerado (plano ACP)")vivienda_activa$cluster <- res.hcpc$data.clust$clust
perfil_cluster <- vivienda_activa %>%
group_by(cluster) %>%
summarise(
n = n(), pct = n() / nrow(vivienda_activa),
precio_prom = mean(preciom), precio_m2_prom = mean(precio_m2),
area_prom = mean(areaconst), banios_prom = mean(banios),
habitaciones_prom = mean(habitaciones), parqueaderos_prom = mean(parqueaderos),
estrato_prom = mean(estrato)
) %>% arrange(desc(precio_prom))
perfil_cluster %>%
kableExtra::kbl(digits = 1, caption = "Perfil promedio por conglomerado") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| cluster | n | pct | precio_prom | precio_m2_prom | area_prom | banios_prom | habitaciones_prom | parqueaderos_prom | estrato_prom |
|---|---|---|---|---|---|---|---|---|---|
| 3 | 852 | 0.1 | 1033.4 | 2942323 | 410.3 | 5.2 | 4.7 | 3.7 | 5.6 |
| 2 | 2091 | 0.3 | 533.0 | 2691804 | 224.7 | 4.1 | 4.4 | 1.9 | 5.0 |
| 1 | 3673 | 0.6 | 276.1 | 2908702 | 97.7 | 2.3 | 2.9 | 1.3 | 4.5 |
cluster_premium <- perfil_cluster$cluster[1]
cluster_economico <- perfil_cluster$cluster[nrow(perfil_cluster)]Ordenando los conglomerados por precio promedio, el conglomerado 3 concentra los inmuebles de mayor valor (1033 millones COP en promedio, con 410 m² promedio), mientras que el conglomerado 1 agrupa la oferta más económica (276 millones COP en promedio). Esta ordenación monótona en precio, área y estrato confirma que la Dimensión 1 del ACP —tamaño/valor— es también el criterio que mejor separa los conglomerados, dando coherencia interna al análisis.
# res.hcpc$desc.var$quanti describe, por conglomerado, qué variables
# cuantitativas se alejan significativamente de la media global (v.test);
# res.hcpc$desc.var$category hace lo propio para variables categóricas
# suplementarias (zona, tipo), vía prueba de proporciones.
res.hcpc$desc.var$quanti## $`1`
## v.test Mean in category Overall mean sd in category Overall sd
## estrato -26.80 4.546 4.824 0.8440 0.943
## parqueaderos -43.83 1.288 1.807 0.4689 1.074
## habitaciones -46.52 2.907 3.606 0.6219 1.365
## areaconst -52.46 97.685 178.093 38.7326 139.267
## preciom -53.39 276.074 454.790 108.9059 304.133
## banios -61.37 2.320 3.239 0.6064 1.361
## p.value
## estrato 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002859
## parqueaderos 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## habitaciones 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## areaconst 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## preciom 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## banios 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## n
## estrato 3673
## parqueaderos 3673
## habitaciones 3673
## areaconst 3673
## preciom 3673
## banios 3673
##
## $`2`
## v.test Mean in category Overall mean sd in category Overall sd
## banios 33.105 4.054 3.239 0.8859 1.361
## habitaciones 32.383 4.406 3.606 1.4700 1.365
## areaconst 18.510 224.718 178.093 89.3393 139.267
## preciom 14.210 532.958 454.790 186.7178 304.133
## estrato 10.091 4.996 4.824 0.9838 0.943
## parqueaderos 6.733 1.937 1.807 0.6679 1.074
## p.value
## banios 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002497
## habitaciones 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048243053653831
## areaconst 0.000000000000000000000000000000000000000000000000000000000000000000000000000172811703986534895627274277073126896917641894631915727262830305780075146251047265146114854555300246775367565688491919113084406777040916627225784152760342866667670533059
## preciom 0.000000000000000000000000000000000000000000000793642341195119437580574279065681225250697890612337133546435188082790739452693594264977334286565253492250780468944375278805836160245235078036785125732421875000000000000000000000000000000000000000000
## estrato 0.000000000000000000000006057556713944972768636703753126413361494581239820244127920796802476421927252658861107192933559417724609375000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## parqueaderos 0.000000000016597452182053207290810137943364564218351464575107456767000257968902587890625000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## n
## banios 2091
## habitaciones 2091
## areaconst 2091
## preciom 2091
## estrato 2091
## parqueaderos 2091
##
## $`3`
## v.test Mean in category Overall mean sd in category Overall sd
## preciom 59.49 1033.401 454.790 306.7396 304.133
## parqueaderos 55.69 3.719 1.807 1.4348 1.074
## areaconst 52.14 410.312 178.093 193.4411 139.267
## banios 45.09 5.202 3.239 1.3230 1.361
## estrato 25.76 5.601 4.824 0.6699 0.943
## habitaciones 24.07 4.657 3.606 1.6501 1.365
## p.value
## preciom 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## parqueaderos 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## areaconst 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## banios 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## estrato 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002418
## habitaciones 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005210555472425525159459
## n
## preciom 852
## parqueaderos 852
## areaconst 852
## banios 852
## estrato 852
## habitaciones 852
tab_cluster_zona <- table(vivienda_activa$cluster, vivienda_activa$zona)
chi_cluster_zona <- chisq.test(tab_cluster_zona)
prop.table(tab_cluster_zona, margin = 1) %>%
as.data.frame() %>%
rename(cluster = Var1, zona = Var2, proporcion = Freq) %>%
ggplot(aes(x = cluster, y = proporcion, fill = zona)) +
geom_col(position = "fill") +
scale_y_continuous(labels = scales::percent) +
labs(x = "Conglomerado", y = "% de inmuebles", fill = "Zona",
title = "Composición de cada conglomerado por zona") +
theme_minimal()La asociación entre conglomerado y zona es estadísticamente significativa (chi-cuadrado, p < 0.05), lo que indica que los segmentos de mercado identificados no se distribuyen de forma homogénea en el territorio, sino que tienden a concentrarse en zonas específicas de la ciudad.
pal_cluster <- colorFactor(RColorBrewer::brewer.pal(max(3, n_clusters), "Set1"),
domain = vivienda_activa$cluster)
leaflet(vivienda_activa) %>%
addProviderTiles(providers$CartoDB.Positron) %>%
addCircleMarkers(
lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
fillOpacity = 0.65, color = ~pal_cluster(cluster),
popup = ~paste0("<b>Conglomerado: </b>", cluster,
"<br><b>Zona: </b>", zona,
"<br><b>Tipo: </b>", tipo,
"<br><b>Precio: </b>", preciom, " M COP",
"<br><b>Área: </b>", areaconst, " m²")
) %>%
addLegend("bottomright", pal = pal_cluster, values = ~cluster, title = "Conglomerado")La segmentación en 3 conglomerados, construida sobre el espacio reducido del ACP, ofrece una tipología de mercado operativa: conglomerados ordenables de forma consistente por precio, tamaño y estrato, con distribución geográfica diferenciada. Esta tipología es la que se retoma en las recomendaciones estratégicas (sección 9).
tab_tipo_zona <- table(vivienda$tipo, vivienda$zona)
tab_tipo_zona %>% as.data.frame() %>%
pivot_wider(names_from = Var2, values_from = Freq) %>%
rename(tipo = Var1) %>%
kableExtra::kbl(caption = "Tabla de contingencia: tipo de vivienda × zona") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| tipo | Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur |
|---|---|---|---|---|---|
| Apartamento | 24 | 1198 | 1029 | 62 | 2787 |
| Casa | 100 | 722 | 169 | 289 | 1939 |
chi_tipo_zona <- chisq.test(tab_tipo_zona)
n_tot <- sum(tab_tipo_zona)
k_min <- min(nrow(tab_tipo_zona), ncol(tab_tipo_zona)) - 1
v_cramer <- sqrt(as.numeric(chi_tipo_zona$statistic) / (n_tot * k_min))La prueba ji-cuadrado de independencia entre tipo de vivienda y zona resulta significativa (p < 0.05) (\(\chi^2\) = 690.9, gl = 4), con una V de Cramér de 0.288 (asociación débil a moderada), lo que confirma que el tipo de inmueble ofertado no es independiente de la zona, justificando el análisis de correspondencias.
res.ca <- CA(tab_tipo_zona, graph = FALSE)
# La dimensionalidad total de un CA está acotada por
# min(n_filas - 1, n_columnas - 1). Si `tipo` solo tiene dos categorías,
# esa cota es 1, y un biplot de 2 ejes (el que pide fviz_ca_biplot por
# defecto) no es aplicable porque no existe una segunda dimensión.
n_dim_ca <- nrow(res.ca$eig)
if (n_dim_ca >= 2) {
factoextra::fviz_ca_biplot(res.ca, repel = TRUE) +
labs(title = "Análisis de Correspondencias Simple — Tipo × Zona")
} else {
# Con una única dimensión disponible, se proyectan ambas variables sobre
# ese único eje en lugar de forzar un biplot de dos ejes.
coord_fila <- as.data.frame(res.ca$row$coord)
coord_fila$categoria <- rownames(res.ca$row$coord)
coord_fila$variable <- "tipo"
names(coord_fila)[1] <- "Dim1"
coord_col <- as.data.frame(res.ca$col$coord)
coord_col$categoria <- rownames(res.ca$col$coord)
coord_col$variable <- "zona"
names(coord_col)[1] <- "Dim1"
bind_rows(coord_fila, coord_col) %>%
ggplot(aes(x = Dim1, y = fct_reorder(categoria, Dim1), color = variable)) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey60") +
geom_point(size = 3) +
labs(x = "Dimensión 1 (única dimensión del CA)", y = NULL, color = NULL,
title = "Análisis de Correspondencias Simple — Tipo × Zona (dimensión única)") +
theme_minimal()
}El CA explica 100% de la inercia total con 1
dimensión (el máximo posible para esta tabla, ya que min(filas−1,
columnas−1) = 1), por lo que la representación anterior resume
adecuadamente la asociación tipo–zona. La proximidad entre una categoría
de tipo y una de zona en el gráfico indica una
asociación positiva —esa combinación aparece con mayor frecuencia
relativa de la esperada bajo independencia—, mientras que las categorías
más alejadas del origen son las que más contribuyen a la asociación
global.
df_mca <- vivienda %>%
select(tipo, zona, barrio_agg) %>%
mutate(across(everything(), as.factor)) %>%
as.data.frame()
res.mca <- MCA(df_mca, graph = FALSE)
factoextra::fviz_eig(res.mca, addlabels = TRUE) +
labs(title = "MCA — varianza explicada por dimensión")Las dos primeras dimensiones del MCA explican en conjunto
16% de la inercia total —un porcentaje moderado,
esperable en MCA dado el elevado número de categorías involucradas
(particularmente en barrio_agg)—, suficiente para una
lectura exploratoria de los patrones dominantes de asociación.
factoextra::fviz_mca_var(res.mca, repel = TRUE, col.var = "contrib",
gradient.cols = c("#2166ac", "#f7f7f7", "#b2182b"),
labelsize = 3) +
labs(title = "MCA — categorías de tipo, zona y barrio en el plano factorial")# Se referencian las columnas por posición (1 y 2), no por nombre: en
# algunos objetos de FactoMineR las columnas de coordenadas/contribuciones
# se llaman "Dim 1" (con espacio) en lugar de "Dim.1", lo que rompía una
# referencia por nombre como en el ACP.
contrib_mca_raw <- as.data.frame(res.mca$var$contrib)
contrib_mca <- data.frame(
categoria = rownames(res.mca$var$contrib),
Dim1 = contrib_mca_raw[[1]],
Dim2 = if (ncol(contrib_mca_raw) >= 2) contrib_mca_raw[[2]] else NA_real_
) %>%
arrange(desc(Dim1)) %>%
head(10)
contrib_mca %>%
kableExtra::kbl(digits = 1, col.names = c("Categoría", "Dim. 1", "Dim. 2"),
caption = "Categorías con mayor contribución a las dos primeras dimensiones del MCA") %>%
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Categoría | Dim. 1 | Dim. 2 |
|---|---|---|
| tipo.NA | 49.9 | 0.0 |
| zona.NA | 49.9 | 0.0 |
| Otros | 0.1 | 0.2 |
| Zona Sur | 0.0 | 6.7 |
| Apartamento | 0.0 | 4.5 |
| valle del lili | 0.0 | 0.6 |
| Zona Oeste | 0.0 | 36.5 |
| santa teresita | 0.0 | 14.1 |
| ciudad jardín | 0.0 | 2.0 |
| pance | 0.0 | 1.2 |
La lectura conjunta del biplot y de la tabla de contribuciones
permite identificar combinaciones características de tipo–zona–barrio:
categorías de barrio_agg que se ubican cerca de una
categoría de tipo o zona particular evidencian
una especialización de la oferta en ese territorio (por
ejemplo, barrios que concentran casi exclusivamente apartamentos frente
a otros dominados por casas), información directamente aplicable a la
segmentación geográfica del portafolio comercial.
El CA simple confirma una asociación estadísticamente significativa entre tipo de vivienda y zona, y el MCA extiende esa lectura incorporando el barrio como unidad geográfica de mayor resolución, revelando patrones de especialización de la oferta que el análisis bivariado por sí solo no permite apreciar.
tab_cluster_tipo <- table(vivienda_activa$cluster, vivienda_activa$tipo)
prop.table(tab_cluster_tipo, margin = 1) %>%
as.data.frame() %>%
rename(cluster = Var1, tipo = Var2, proporcion = Freq) %>%
ggplot(aes(x = cluster, y = proporcion, fill = tipo)) +
geom_col(position = "fill") +
scale_y_continuous(labels = scales::percent) +
labs(x = "Conglomerado", y = "% de inmuebles", fill = "Tipo",
title = "Composición de cada conglomerado por tipo de vivienda") +
theme_minimal()pal_precio <- colorNumeric("YlOrRd", domain = vivienda$preciom)
leaflet(vivienda) %>%
addProviderTiles(providers$CartoDB.Positron) %>%
addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 3, stroke = FALSE,
fillOpacity = 0.55, color = ~pal_precio(preciom)) %>%
addLegend("bottomright", pal = pal_precio, values = ~preciom,
title = "Precio (M COP)")Los tres análisis convergen en una misma lectura del mercado: el eje dominante de variación es el tamaño/valor del inmueble (ACP), que produce una segmentación clara y geográficamente diferenciada en conglomerados (HCPC), y esa segmentación es consistente con patrones de especialización de la oferta por tipo de vivienda, zona y barrio (CA/MCA). Esta triangulación metodológica —tres técnicas distintas apuntando al mismo patrón subyacente— refuerza la validez de los hallazgos.
El análisis se basa en precios de oferta (anuncios), no en precios de cierre de transacción, por lo que puede sobreestimar el nivel general de precios. La cobertura depende de la disponibilidad de anuncios en OLX, lo que puede subrepresentar ciertos segmentos del mercado (p. ej., vivienda nueva comercializada directamente por constructoras). Como extensión natural de este trabajo se propone un modelo de precios hedónicos (regresión) que cuantifique el efecto marginal de cada atributo controlando por zona y estrato, así como el enriquecimiento de la base con variables de contexto (cercanía a vías principales, equipamientos urbanos).
Greenacre, M. (2007). Correspondence Analysis in Practice (2nd ed.). Chapman & Hall/CRC.
Husson, F., Lê, S., & Pagès, J. (2017). Exploratory Multivariate Analysis by Example Using R (2nd ed.). CRC Press.
Jolliffe, I. T. (2002). Principal Component Analysis (2nd ed.). Springer.
Kaufman, L., & Rousseeuw, P. J. (1990). Finding Groups in Data: An Introduction to Cluster Analysis. Wiley.
Ward, J. H. (1963). Hierarchical grouping to optimize an objective function. Journal of the American Statistical Association, 58(301), 236–244.
## R version 4.5.1 (2025-06-13)
## Platform: aarch64-apple-darwin20
## Running under: macOS Tahoe 26.5.2
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: America/Bogota
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] gridExtra_2.3.1 e1071_1.7-16 remotes_2.5.0 RColorBrewer_1.1-3
## [5] scales_1.4.0 kableExtra_1.4.0 DT_0.34.0 leaflet_2.2.3
## [9] corrplot_0.95 psych_2.6.5 cluster_2.1.8.3 factoextra_2.2.0
## [13] FactoMineR_2.16 lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0
## [17] dplyr_1.2.1 purrr_1.2.2 readr_2.2.0 tidyr_1.3.2
## [21] tibble_3.3.1 ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] tidyselect_1.2.1 viridisLite_0.4.3 farver_2.1.2
## [4] S7_0.2.2 fastmap_1.2.0 digest_0.6.39
## [7] timechange_0.4.0 estimability_2.0.0 lifecycle_1.0.5
## [10] multcompView_0.1-12 magrittr_2.0.5 compiler_4.5.1
## [13] rlang_1.3.0 sass_0.4.10 tools_4.5.1
## [16] yaml_2.3.12 ggsignif_0.6.4 knitr_1.51
## [19] labeling_0.4.3 htmlwidgets_1.6.4 mnormt_2.1.2
## [22] scatterplot3d_0.3-45 xml2_1.5.1 showtextdb_3.0
## [25] abind_1.4-8 withr_3.0.3 grid_4.5.1
## [28] ggpubr_1.0.0 sysfonts_0.8.9 xtable_1.8-4
## [31] emmeans_2.0.4 MASS_7.3-65 flashClust_1.1-4
## [34] cli_3.6.6 mvtnorm_1.4-2 crayon_1.5.3
## [37] rmarkdown_2.30 generics_0.1.4 otel_0.2.0
## [40] rstudioapi_0.17.1 tzdb_0.5.0 proxy_0.4-28
## [43] cachem_1.1.0 parallel_4.5.1 vctrs_0.7.3
## [46] Matrix_1.7-3 carData_3.0-6 jsonlite_2.0.0
## [49] car_3.1-5 hms_1.1.4 rstatix_1.1.0
## [52] ggrepel_0.9.8 Formula_1.2-5 irlba_2.3.7
## [55] systemfonts_1.3.1 crosstalk_1.2.2 jquerylib_0.1.4
## [58] glue_1.8.1 leaflet.providers_3.0.0 ggtext_0.1.2
## [61] stringi_1.8.9 gtable_0.3.6 pillar_1.11.1
## [64] htmltools_0.5.9 showtext_0.9-8 R6_2.6.1
## [67] textshaping_1.0.4 evaluate_1.0.5 lattice_0.22-7
## [70] backports_1.5.1 leaps_3.2 gridtext_0.1.6
## [73] broom_1.0.13 bslib_0.9.0 class_7.3-23
## [76] Rcpp_1.1.2 svglite_2.2.2 nlme_3.1-168
## [79] xfun_0.60 pkgconfig_2.0.3