Este informe presenta un análisis integral de la oferta de vivienda
urbana registrada en la base vivienda, obtenida
originalmente mediante web scraping de anuncios publicados en
OLX y distribuida en el paquete paqueteMODELOS. El
propósito es reconocer los factores que explican la variabilidad de las
propiedades, establecer segmentos homogéneos del mercado, identificar
asociaciones entre las características territoriales y el tipo de
vivienda, y comunicar los resultados mediante recursos gráficos y
geográficos.
El estudio combina cuatro procedimientos: análisis descriptivo, análisis de componentes principales (PCA), análisis de conglomerados mediante k-medias y análisis de correspondencias simple y múltiple. Los resultados se interpretan como patrones exploratorios de la oferta publicada y no como evidencia causal ni como precios finales de compraventa.
Una empresa inmobiliaria necesita comprender la estructura del mercado urbano para orientar sus decisiones de compra, venta y valoración. La base contiene información sobre precio, área construida, número de parqueaderos, baños, habitaciones, tipo de vivienda, zona, barrio, estrato y ubicación geográfica. Debido a la diversidad de las propiedades, un promedio general no resulta suficiente para describir el mercado; es necesario reducir la dimensionalidad, construir segmentos y estudiar simultáneamente las relaciones entre variables cuantitativas y cualitativas.
Analizar multidimensionalmente la oferta de viviendas urbanas mediante técnicas de componentes principales, conglomerados y correspondencias para apoyar la toma de decisiones estratégicas de una empresa inmobiliaria.
El siguiente bloque instala únicamente los paquetes que no se
encuentren disponibles. Se utiliza remotes para instalar
paqueteMODELOS, evitando la instalación completa de
devtools.
paquetes <- c(
"remotes", "dplyr", "tidyr", "tibble", "ggplot2", "scales",
"FactoMineR", "factoextra", "cluster", "corrplot", "psych",
"leaflet", "knitr", "rmarkdown", "ggrepel"
)
faltantes <- paquetes[
!vapply(paquetes, requireNamespace, logical(1), quietly = TRUE)
]
if (length(faltantes) > 0) {
install.packages(
faltantes,
repos = "https://cloud.r-project.org",
dependencies = TRUE
)
}
if (!requireNamespace("paqueteMODELOS", quietly = TRUE)) {
remotes::install_github(
"dgonxalex80/paqueteMODELOS",
upgrade = "never"
)
}
suppressPackageStartupMessages({
library(paqueteMODELOS)
library(dplyr)
library(tidyr)
library(tibble)
library(ggplot2)
library(scales)
library(FactoMineR)
library(factoextra)
library(cluster)
library(corrplot)
library(psych)
library(leaflet)
library(knitr)
})data("vivienda", package = "paqueteMODELOS")
variables_requeridas <- c(
"id", "zona", "piso", "estrato", "preciom", "areaconst",
"parqueaderos", "banios", "habitaciones", "tipo", "barrio",
"longitud", "latitud"
)
variables_ausentes <- setdiff(variables_requeridas, names(vivienda))
if (length(variables_ausentes) > 0) {
stop(
"La base no contiene estas variables: ",
paste(variables_ausentes, collapse = ", ")
)
}
datos <- vivienda |>
as.data.frame() |>
as_tibble() |>
mutate(
zona = na_if(trimws(as.character(zona)), ""),
piso = na_if(trimws(as.character(piso)), ""),
tipo = na_if(trimws(as.character(tipo)), ""),
barrio = na_if(trimws(as.character(barrio)), "")
)
dimensiones <- data.frame(
Indicador = c("Número de propiedades", "Número de variables"),
Resultado = c(nrow(datos), ncol(datos))
)
kable(dimensiones, caption = "Dimensiones de la base original")| Indicador | Resultado |
|---|---|
| Número de propiedades | 8322 |
| Número de variables | 13 |
La unidad de análisis es cada propiedad residencial anunciada.
preciom representa el precio publicado en millones de pesos
y areaconst corresponde al área construida en metros
cuadrados.
## Rows: 8,322
## Columns: 13
## $ id <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud <dbl> -76.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…
clasificacion <- data.frame(
Variable = variables_requeridas,
Tipo = c(
"Identificador", "Cualitativa nominal", "Cualitativa ordinal",
"Cualitativa ordinal", "Cuantitativa continua",
"Cuantitativa continua", "Cuantitativa discreta",
"Cuantitativa discreta", "Cuantitativa discreta",
"Cualitativa nominal", "Cualitativa nominal",
"Coordenada geográfica", "Coordenada geográfica"
),
Uso = c(
"Control de duplicados", "Correspondencias y segmentación",
"Excluida por datos faltantes", "Correspondencias y perfil",
"PCA y conglomerados", "PCA y conglomerados",
"PCA y conglomerados", "PCA y conglomerados",
"PCA y conglomerados", "Correspondencias y perfil",
"Correspondencias y perfil", "Mapa", "Mapa"
)
)
kable(
clasificacion,
caption = "Clasificación y utilización de las variables",
align = c("l", "l", "l")
)| Variable | Tipo | Uso |
|---|---|---|
| id | Identificador | Control de duplicados |
| zona | Cualitativa nominal | Correspondencias y segmentación |
| piso | Cualitativa ordinal | Excluida por datos faltantes |
| estrato | Cualitativa ordinal | Correspondencias y perfil |
| preciom | Cuantitativa continua | PCA y conglomerados |
| areaconst | Cuantitativa continua | PCA y conglomerados |
| parqueaderos | Cuantitativa discreta | PCA y conglomerados |
| banios | Cuantitativa discreta | PCA y conglomerados |
| habitaciones | Cuantitativa discreta | PCA y conglomerados |
| tipo | Cualitativa nominal | Correspondencias y perfil |
| barrio | Cualitativa nominal | Correspondencias y perfil |
| longitud | Coordenada geográfica | Mapa |
| latitud | Coordenada geográfica | Mapa |
Aunque estrato está almacenado como número,
conceptualmente es una variable categórica ordinal. El identificador
id no se incorpora a los modelos; las coordenadas se
reservan para la visualización geográfica y piso se excluye
del análisis multivariado debido a sus valores faltantes y a su
interpretación diferente en casas y apartamentos.
calidad <- data.frame(
Variable = names(datos),
Clase = vapply(
datos,
function(x) paste(class(x), collapse = "/"),
character(1)
),
Faltantes = vapply(datos, function(x) sum(is.na(x)), numeric(1)),
Porcentaje = round(
100 * vapply(datos, function(x) mean(is.na(x)), numeric(1)),
2
),
row.names = NULL
)
kable(calidad, caption = "Diagnóstico de valores faltantes")| Variable | Clase | Faltantes | Porcentaje |
|---|---|---|---|
| id | numeric | 3 | 0.04 |
| zona | character | 3 | 0.04 |
| piso | character | 2638 | 31.70 |
| estrato | numeric | 3 | 0.04 |
| preciom | numeric | 2 | 0.02 |
| areaconst | numeric | 3 | 0.04 |
| parqueaderos | numeric | 1605 | 19.29 |
| banios | numeric | 3 | 0.04 |
| habitaciones | numeric | 3 | 0.04 |
| tipo | character | 3 | 0.04 |
| barrio | character | 3 | 0.04 |
| longitud | numeric | 3 | 0.04 |
| latitud | numeric | 3 | 0.04 |
duplicados <- sum(duplicated(datos$id))
kable(
data.frame(Indicador = "Identificadores repetidos", Resultado = duplicados),
caption = "Revisión de posibles duplicados"
)| Indicador | Resultado |
|---|---|
| Identificadores repetidos | 2 |
Un identificador repetido puede corresponder a un anuncio republicado o a un error de extracción. Por esta razón, se documenta el resultado, pero no se elimina automáticamente sin contar con información adicional que permita confirmar la duplicidad real del inmueble.
variables_numericas <- c(
"preciom", "areaconst", "parqueaderos", "banios", "habitaciones"
)
variables_categoricas <- c("zona", "estrato", "tipo", "barrio")
base_completa <- datos |>
filter(
if_all(
all_of(c(
variables_numericas, variables_categoricas,
"longitud", "latitud"
)),
~ !is.na(.)
)
) |>
filter(if_all(all_of(variables_numericas), ~ is.finite(.))) |>
filter(
preciom > 0,
areaconst > 0,
parqueaderos >= 0,
banios > 0,
habitaciones >= 0
)
limite_precio <- quantile(
base_completa$preciom,
probs = c(0.01, 0.99),
na.rm = TRUE
)
limite_area <- quantile(
base_completa$areaconst,
probs = c(0.01, 0.99),
na.rm = TRUE
)
vivienda_limpia <- base_completa |>
filter(
between(preciom, limite_precio[1], limite_precio[2]),
between(areaconst, limite_area[1], limite_area[2])
) |>
mutate(
zona = factor(zona),
estrato = factor(
estrato,
levels = sort(unique(estrato)),
ordered = TRUE
),
tipo = factor(tipo),
barrio = factor(barrio)
)
flujo_limpieza <- data.frame(
Etapa = c(
"Base original",
"Sin faltantes ni valores inválidos",
"Después del control de valores atípicos"
),
Registros = c(
nrow(datos),
nrow(base_completa),
nrow(vivienda_limpia)
)
)
kable(flujo_limpieza, caption = "Registros conservados durante la limpieza")| Etapa | Registros |
|---|---|
| Base original | 8322 |
| Sin faltantes ni valores inválidos | 6702 |
| Después del control de valores atípicos | 6480 |
Para limitar la influencia de observaciones extremas en las
distancias utilizadas por el PCA y los conglomerados, se excluye el 1 %
inferior y superior de preciom y areaconst.
Este procedimiento es una decisión operativa de robustez y no implica
que todas las propiedades retiradas sean registros erróneos.
resumen_numerico <- vivienda_limpia |>
select(all_of(variables_numericas)) |>
pivot_longer(
cols = everything(),
names_to = "Variable",
values_to = "Valor"
) |>
group_by(Variable) |>
summarise(
n = n(),
Media = mean(Valor),
Desviación = sd(Valor),
Mínimo = min(Valor),
Q1 = quantile(Valor, 0.25),
Mediana = median(Valor),
Q3 = quantile(Valor, 0.75),
Máximo = max(Valor),
CV = 100 * Desviación / Media,
.groups = "drop"
) |>
mutate(across(where(is.numeric), ~ round(., 2)))
kable(
resumen_numerico,
caption = "Estadísticos descriptivos de las variables cuantitativas"
)| Variable | n | Media | Desviación | Mínimo | Q1 | Mediana | Q3 | Máximo | CV |
|---|---|---|---|---|---|---|---|---|---|
| areaconst | 6480 | 173.25 | 118.27 | 52 | 86 | 130 | 229 | 724 | 68.27 |
| banios | 6480 | 3.25 | 1.33 | 1 | 2 | 3 | 4 | 10 | 40.88 |
| habitaciones | 6480 | 3.61 | 1.33 | 0 | 3 | 3 | 4 | 10 | 36.99 |
| parqueaderos | 6480 | 1.80 | 1.05 | 1 | 1 | 2 | 2 | 10 | 58.23 |
| preciom | 6480 | 456.40 | 303.96 | 110 | 250 | 355 | 570 | 1700 | 66.60 |
La comparación entre media y mediana permite reconocer posibles asimetrías. Además, el coeficiente de variación muestra la heterogeneidad relativa de cada variable y ayuda a justificar la estandarización previa a los análisis multivariados.
ggplot(vivienda_limpia, aes(x = tipo, y = preciom, fill = tipo)) +
geom_boxplot(alpha = 0.80, outlier.alpha = 0.15) +
scale_y_continuous(
labels = label_number(big.mark = ".", decimal.mark = ",")
) +
labs(
title = "Distribución del precio según el tipo de vivienda",
x = "Tipo de vivienda",
y = "Precio (millones de pesos)",
fill = "Tipo"
) +
theme_minimal(base_size = 12) +
theme(legend.position = "none")ggplot(
vivienda_limpia,
aes(x = reorder(zona, preciom, FUN = median), y = preciom, fill = zona)
) +
geom_boxplot(alpha = 0.80, outlier.alpha = 0.15) +
scale_y_continuous(
labels = label_number(big.mark = ".", decimal.mark = ",")
) +
labs(
title = "Distribución del precio según la zona",
x = "Zona",
y = "Precio (millones de pesos)",
fill = "Zona"
) +
theme_minimal(base_size = 12) +
theme(
axis.text.x = element_text(angle = 30, hjust = 1),
legend.position = "none"
)matriz_correlacion <- vivienda_limpia |>
select(all_of(variables_numericas)) |>
cor(method = "pearson")
corrplot(
matriz_correlacion,
method = "color",
type = "upper",
addCoef.col = "black",
tl.col = "black",
tl.srt = 35,
number.cex = 0.85
)Los coeficientes cercanos a 1 representan relaciones lineales positivas fuertes, mientras que los valores próximos a 0 indican una relación lineal débil. La correlación no demuestra causalidad y debe interpretarse junto con el contexto inmobiliario.
El PCA transforma las variables cuantitativas correlacionadas en un conjunto menor de componentes no correlacionados. Debido a que precio, área y variables de conteo se expresan en escalas distintas, todas las variables se estandarizan.
matriz_pca <- vivienda_limpia |>
select(all_of(variables_numericas)) |>
as.data.frame()
if (nrow(matriz_pca) < 3L ||
any(!vapply(matriz_pca, function(x) is.finite(sd(x)) && sd(x) > 0, logical(1)))) {
stop("El PCA requiere al menos tres registros y variables con variación.")
}
kmo <- psych::KMO(matriz_correlacion)
bartlett <- psych::cortest.bartlett(
matriz_correlacion,
n = nrow(matriz_pca)
)
tabla_adecuacion <- data.frame(
Indicador = c(
"KMO global",
"Chi-cuadrado de Bartlett",
"Grados de libertad",
"Valor p"
),
Resultado = c(
kmo$MSA,
bartlett$chisq,
bartlett$df,
bartlett$p.value
)
)
kable(
tabla_adecuacion,
digits = 4,
caption = "Pruebas de adecuación para el PCA"
)| Indicador | Resultado |
|---|---|
| KMO global | 0.7633 |
| Chi-cuadrado de Bartlett | 18354.0850 |
| Grados de libertad | 10.0000 |
| Valor p | 0.0000 |
Un KMO igual o superior a 0,60 se considera una evidencia razonable de adecuación muestral. Un valor p inferior a 0,05 en la prueba de Bartlett permite rechazar la hipótesis de que la matriz de correlaciones sea una matriz identidad.
modelo_pca <- prcomp(
matriz_pca,
center = TRUE,
scale. = TRUE
)
varianza_pca <- factoextra::get_eigenvalue(modelo_pca) |>
as.data.frame() |>
rownames_to_column("Componente")
cargas_pca <- as.data.frame(modelo_pca$rotation) |>
rownames_to_column("Variable")
varianza_dos_componentes <- round(
sum(varianza_pca$variance.percent[1:2]),
2
)
variables_pc1 <- paste(
head(
cargas_pca$Variable[order(abs(cargas_pca$PC1), decreasing = TRUE)],
3
),
collapse = ", "
)
variables_pc2 <- paste(
head(
cargas_pca$Variable[order(abs(cargas_pca$PC2), decreasing = TRUE)],
3
),
collapse = ", "
)
kable(
varianza_pca,
digits = 2,
caption = "Varianza explicada por los componentes principales"
)| Componente | eigenvalue | variance.percent | cumulative.variance.percent |
|---|---|---|---|
| Dim.1 | 3.26 | 65.27 | 65.27 |
| Dim.2 | 0.88 | 17.67 | 82.94 |
| Dim.3 | 0.38 | 7.55 | 90.49 |
| Dim.4 | 0.29 | 5.77 | 96.25 |
| Dim.5 | 0.19 | 3.75 | 100.00 |
Los dos primeros componentes explican 82.94 % de la variabilidad total. Las variables con mayor peso absoluto en el primer componente son areaconst, banios, preciom; en el segundo componente se destacan habitaciones, parqueaderos, preciom. El signo de una carga puede invertirse sin modificar el significado matemático del componente, por lo cual la interpretación se concentra en su magnitud y en la dirección relativa de las variables.
fviz_eig(
modelo_pca,
addlabels = TRUE,
barfill = "#2C7FB8",
barcolor = "#164B6B",
linecolor = "#D95F0E",
ylim = c(0, 100)
) +
labs(title = "Gráfico de sedimentación del PCA") +
theme_minimal(base_size = 12)fviz_pca_var(
modelo_pca,
col.var = "contrib",
gradient.cols = c("#2C7BB6", "#FFFF8C", "#D7191C"),
repel = TRUE
) +
labs(title = "Variables en el plano de componentes principales") +
theme_minimal(base_size = 12)Los vectores que apuntan en direcciones semejantes representan variables positivamente relacionadas. Los vectores opuestos sugieren asociaciones negativas, mientras que los cercanos a un ángulo recto representan relaciones débiles. Los vectores más largos y próximos al borde del círculo están mejor representados en el plano.
| Variable | PC1 | PC2 | PC3 | PC4 | PC5 |
|---|---|---|---|---|---|
| preciom | 0.460 | 0.431 | -0.410 | -0.015 | -0.659 |
| areaconst | 0.491 | -0.100 | -0.293 | 0.683 | 0.444 |
| parqueaderos | 0.428 | 0.437 | 0.785 | 0.031 | 0.096 |
| banios | 0.490 | -0.131 | -0.215 | -0.729 | 0.407 |
| habitaciones | 0.352 | -0.772 | 0.289 | 0.044 | -0.440 |
Se aplica el algoritmo de k-medias sobre las cinco variables cuantitativas estandarizadas. El número de conglomerados se selecciona mediante el promedio del índice de silueta. Para esta evaluación se utiliza una muestra reproducible de hasta 1.500 propiedades, con el fin de evitar una matriz de distancias excesivamente grande.
matriz_escalada <- scale(matriz_pca)
tamano_muestra <- min(1500, nrow(matriz_escalada))
indices_muestra <- sample(seq_len(nrow(matriz_escalada)), tamano_muestra)
muestra_cluster <- matriz_escalada[indices_muestra, , drop = FALSE]
max_k <- min(7L, nrow(muestra_cluster) - 1L, nrow(unique(muestra_cluster)))
if (max_k < 2L) stop("No hay suficientes perfiles distintos para los conglomerados.")
valores_k <- seq.int(2L, max_k)
distancias_muestra <- dist(muestra_cluster)
validacion <- lapply(valores_k, function(k) {
set.seed(2026 + k)
modelo_temporal <- kmeans(
muestra_cluster,
centers = k,
nstart = 30,
iter.max = 100
)
silueta <- silhouette(
modelo_temporal$cluster,
distancias_muestra
)
data.frame(
k = k,
WSS = modelo_temporal$tot.withinss,
Silueta = mean(silueta[, "sil_width"])
)
}) |>
bind_rows()
k_optimo <- validacion$k[which.max(validacion$Silueta)]
kable(
validacion,
digits = 4,
caption = "Validación del número de conglomerados"
)| k | WSS | Silueta |
|---|---|---|
| 2 | 3964.906 | 0.4842 |
| 3 | 3097.580 | 0.3774 |
| 4 | 2538.063 | 0.3655 |
| 5 | 2265.135 | 0.3473 |
| 6 | 2074.904 | 0.3141 |
| 7 | 1933.927 | 0.3044 |
ggplot(validacion, aes(x = k, y = Silueta)) +
geom_line(color = "#2C7FB8", linewidth = 1) +
geom_point(color = "#D95F0E", size = 3) +
geom_vline(
xintercept = k_optimo,
color = "#238B45",
linetype = "dashed"
) +
scale_x_continuous(breaks = valores_k) +
labs(
title = "Selección del número de conglomerados",
subtitle = paste("Mayor silueta promedio: k =", k_optimo),
x = "Número de conglomerados",
y = "Silueta promedio"
) +
theme_minimal(base_size = 12)La mayor silueta promedio se obtiene con 2 conglomerados. Este valor se utiliza para ajustar el modelo definitivo con todas las observaciones de la base depurada.
set.seed(2026)
modelo_kmeans <- kmeans(
matriz_escalada,
centers = k_optimo,
nstart = 50,
iter.max = 200
)
vivienda_cluster <- vivienda_limpia |>
mutate(Conglomerado = factor(modelo_kmeans$cluster))
perfil_cluster <- vivienda_cluster |>
group_by(Conglomerado) |>
summarise(
Propiedades = n(),
Porcentaje = 100 * n() / nrow(vivienda_cluster),
across(
all_of(variables_numericas),
list(Media = mean, Mediana = median),
.names = "{.col}_{.fn}"
),
.groups = "drop"
) |>
mutate(across(where(is.numeric), ~ round(., 2)))
kable(
perfil_cluster,
caption = "Perfil económico y físico de los conglomerados"
)| Conglomerado | Propiedades | Porcentaje | preciom_Media | preciom_Mediana | areaconst_Media | areaconst_Mediana | parqueaderos_Media | parqueaderos_Mediana | banios_Media | banios_Mediana | habitaciones_Media | habitaciones_Mediana |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 4529 | 69.89 | 315.56 | 295 | 114.78 | 100 | 1.40 | 1 | 2.61 | 2 | 3.14 | 3 |
| 2 | 1951 | 30.11 | 783.33 | 700 | 308.99 | 296 | 2.74 | 2 | 4.74 | 5 | 4.70 | 4 |
Los números asignados a los conglomerados son etiquetas nominales; no expresan una jerarquía. La denominación estratégica de cada segmento debe basarse en sus medianas de precio, área, parqueaderos, baños y habitaciones.
coordenadas <- as.data.frame(modelo_pca$x[, 1:2, drop = FALSE]) |>
mutate(Conglomerado = vivienda_cluster$Conglomerado)
ggplot(
coordenadas,
aes(x = PC1, y = PC2, color = Conglomerado)
) +
geom_point(alpha = 0.40, size = 0.75) +
stat_ellipse(linewidth = 0.8, show.legend = FALSE) +
labs(
title = "Conglomerados proyectados sobre el plano del PCA",
x = "Componente principal 1",
y = "Componente principal 2",
color = "Conglomerado"
) +
theme_minimal(base_size = 12)tabla_cluster_zona <- prop.table(
table(vivienda_cluster$Conglomerado, vivienda_cluster$zona),
margin = 1
) * 100
kable(
round(tabla_cluster_zona, 2),
caption = "Distribución porcentual de las zonas dentro de cada conglomerado"
)| Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur |
|---|---|---|---|---|
| 1.04 | 21.11 | 13.38 | 2.05 | 62.42 |
| 0.87 | 13.94 | 23.42 | 3.02 | 58.74 |
tabla_cluster_estrato <- prop.table(
table(vivienda_cluster$Conglomerado, vivienda_cluster$estrato),
margin = 1
) * 100
kable(
round(tabla_cluster_estrato, 2),
caption = "Distribución porcentual de los estratos dentro de cada conglomerado"
)| 3 | 4 | 5 | 6 |
|---|---|---|---|
| 10.58 | 30.54 | 41.80 | 17.09 |
| 6.87 | 11.48 | 29.88 | 51.77 |
vivienda_cluster |>
count(Conglomerado, zona) |>
group_by(Conglomerado) |>
mutate(Porcentaje = 100 * n / sum(n)) |>
ungroup() |>
ggplot(aes(x = Conglomerado, y = Porcentaje, fill = zona)) +
geom_col() +
labs(
title = "Composición territorial de los conglomerados",
x = "Conglomerado",
y = "Porcentaje",
fill = "Zona"
) +
theme_minimal(base_size = 12)El análisis de correspondencias explora los perfiles de las categorías de una tabla de contingencia. La prueba de independencia chi-cuadrado evalúa la asociación y la V de Cramér resume su intensidad. Primero se crean las tablas y después se utilizan en los modelos y las pruebas.
limpiar_tabla <- function(tabla) {
tabla <- as.matrix(tabla)
tabla <- tabla[
rowSums(tabla) > 0,
colSums(tabla) > 0,
drop = FALSE
]
if (nrow(tabla) < 2L || ncol(tabla) < 2L) {
stop("La tabla requiere al menos dos categorías observadas por variable.")
}
tabla
}
resumen_asociacion <- function(tabla, relacion) {
tabla <- limpiar_tabla(tabla)
prueba <- suppressWarnings(stats::chisq.test(tabla, correct = FALSE))
escasa <- any(prueba$expected < 1) || mean(prueba$expected < 5) > 0.20
valor_p <- prueba$p.value
metodo <- "Chi-cuadrado asintótico"
if (escasa) {
# Monte Carlo conserva los márgenes de la tabla.
set.seed(2026)
prueba_mc <- stats::chisq.test(
tabla, simulate.p.value = TRUE, B = 9999
)
valor_p <- prueba_mc$p.value
metodo <- "Monte Carlo (9999 réplicas)"
}
data.frame(
Relacion = relacion,
Chi_cuadrado = unname(prueba$statistic),
GL_Pearson = unname(prueba$parameter),
Valor_p = valor_p,
V_Cramer = sqrt(
unname(prueba$statistic) /
(sum(tabla) * min(nrow(tabla) - 1L, ncol(tabla) - 1L))
),
Esperadas_menor_5_pct = 100 * mean(prueba$expected < 5),
Metodo_p = metodo
)
}
# Evita solicitar una segunda dimensión cuando solo existe una.
graficar_ca <- function(modelo, titulo, grupos, colores) {
filas <- as.matrix(modelo$row$coord)
columnas <- as.matrix(modelo$col$coord)
dimensiones <- min(ncol(filas), ncol(columnas))
if (dimensiones >= 2L) {
return(
factoextra::fviz_ca_biplot(
modelo, axes = c(1, 2), repel = TRUE,
col.row = colores[1], col.col = colores[2]
) +
ggplot2::labs(title = titulo) +
ggplot2::theme_minimal(base_size = 12)
)
}
puntos <- data.frame(
Categoria = c(rownames(filas), rownames(columnas)),
Dimension1 = c(filas[, 1], columnas[, 1]),
Grupo = c(rep(grupos[1], nrow(filas)), rep(grupos[2], nrow(columnas)))
)
ggplot2::ggplot(
puntos,
ggplot2::aes(x = Dimension1, y = 0, color = Grupo, label = Categoria)
) +
ggplot2::geom_hline(yintercept = 0, color = "grey80") +
ggplot2::geom_vline(xintercept = 0, linetype = "dashed", color = "grey60") +
ggplot2::geom_point(size = 3) +
ggrepel::geom_text_repel(seed = 2026, max.overlaps = Inf) +
ggplot2::scale_color_manual(values = stats::setNames(colores, grupos)) +
ggplot2::scale_y_continuous(breaks = NULL, limits = c(-1, 1)) +
ggplot2::labs(
title = titulo,
subtitle = "Representación unidimensional: solo el eje horizontal se interpreta",
x = "Dimensión 1", y = NULL, color = NULL
) +
ggplot2::theme_minimal(base_size = 12)
}Con dos tipos de vivienda, el máximo de dimensiones es uno: el mínimo entre el número de filas menos uno y el número de columnas menos uno. En ese caso se representa un eje, evitando el error de dimensiones del gráfico biplot.
tabla_tipo_zona <- limpiar_tabla(
table(vivienda_cluster$tipo, vivienda_cluster$zona)
)
knitr::kable(tabla_tipo_zona, caption = "Frecuencias por tipo de vivienda y zona")| Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur | |
|---|---|---|---|---|---|
| Apartamento | 10 | 813 | 938 | 14 | 2323 |
| Casa | 54 | 415 | 125 | 138 | 1650 |
modelo_ca_tipo_zona <- FactoMineR::CA(
tabla_tipo_zona,
ncp = min(2L, nrow(tabla_tipo_zona) - 1L, ncol(tabla_tipo_zona) - 1L),
graph = FALSE
)
print(graficar_ca(
modelo_ca_tipo_zona,
"Correspondencias entre tipo de vivienda y zona",
c("Tipo de vivienda", "Zona"),
c("#D95F0E", "#2C7FB8")
))Las distancias entre categorías de la misma variable resumen diferencias entre sus perfiles, sujetas a la calidad de representación. La cercanía entre un tipo y una zona no debe interpretarse por sí sola como una medida directa de asociación. La contribución depende tanto de la posición como de la masa de cada categoría.
Para facilitar la interpretación se conservan los 15 barrios con mayor número de anuncios; los restantes se agrupan en “Otros”. La relación zona-barrio puede reflejar la propia organización territorial, por lo que no constituye por sí sola una preferencia del mercado.
barrios_principales <- vivienda_cluster |>
dplyr::count(barrio, sort = TRUE) |>
dplyr::slice_head(n = 15) |>
dplyr::pull(barrio) |>
as.character()
vivienda_ca <- vivienda_cluster |>
dplyr::mutate(
barrio_reducido = factor(dplyr::if_else(
as.character(barrio) %in% barrios_principales,
as.character(barrio), "Otros"
))
)
tabla_zona_barrio <- limpiar_tabla(
table(vivienda_ca$zona, vivienda_ca$barrio_reducido)
)modelo_ca_zona_barrio <- FactoMineR::CA(
tabla_zona_barrio,
ncp = min(2L, nrow(tabla_zona_barrio) - 1L, ncol(tabla_zona_barrio) - 1L),
graph = FALSE
)
print(graficar_ca(
modelo_ca_zona_barrio,
"Correspondencias entre zona y barrios con mayor oferta",
c("Zona", "Barrio"),
c("#238B45", "#6A51A3")
))datos_mca <- vivienda_ca |>
dplyr::transmute(
tipo = factor(as.character(tipo)),
zona = factor(as.character(zona)),
barrio = factor(as.character(barrio_reducido)),
estrato = factor(as.character(estrato))
) |>
as.data.frame() |>
droplevels()
# Una variable constante no aporta información al análisis.
variables_activas <- vapply(datos_mca, nlevels, integer(1)) > 1L
datos_mca <- datos_mca[, variables_activas, drop = FALSE]
if (ncol(datos_mca) < 2L) {
stop("El ACM requiere al menos dos variables con categorías diferentes.")
}
dimensiones_mca <- min(
5L, nrow(datos_mca) - 1L,
sum(vapply(datos_mca, nlevels, integer(1))) - ncol(datos_mca)
)
modelo_mca <- FactoMineR::MCA(datos_mca, ncp = dimensiones_mca, graph = FALSE)
if (ncol(as.matrix(modelo_mca$var$coord)) >= 2L) {
print(
factoextra::fviz_mca_var(
modelo_mca, axes = c(1, 2), repel = TRUE,
col.var = "cos2",
gradient.cols = c("#2C7BB6", "#FFFF8C", "#D7191C")
) +
ggplot2::labs(title = "Correspondencias múltiples: categorías activas") +
ggplot2::theme_minimal(base_size = 12)
)
} else {
knitr::kable(modelo_mca$var$coord, caption = "Coordenadas del ACM unidimensional")
}# Las tablas se crean aquí también para que este bloque no dependa
# de la ejecución de los gráficos (sí requiere vivienda_ca y las funciones).
tabla_tipo_zona <- limpiar_tabla(
table(vivienda_ca$tipo, vivienda_ca$zona)
)
tabla_zona_barrio <- limpiar_tabla(
table(vivienda_ca$zona, vivienda_ca$barrio_reducido)
)
asociaciones <- dplyr::bind_rows(
resumen_asociacion(tabla_tipo_zona, "Tipo de vivienda - Zona"),
resumen_asociacion(tabla_zona_barrio, "Zona - Barrio")
) |>
dplyr::mutate(
Intensidad_orientativa = dplyr::case_when(
V_Cramer < 0.10 ~ "Muy débil",
V_Cramer < 0.30 ~ "Débil",
V_Cramer < 0.50 ~ "Moderada",
TRUE ~ "Fuerte"
)
)
# Formatear los valores p sin mostrarlos como cero por redondeo.
asociaciones_mostrar <- asociaciones |>
dplyr::mutate(Valor_p = format.pval(Valor_p, digits = 3, eps = 0.0001))
knitr::kable(
asociaciones_mostrar, digits = 4,
caption = "Pruebas de asociación entre variables categóricas"
)| Relacion | Chi_cuadrado | GL_Pearson | Valor_p | V_Cramer | Esperadas_menor_5_pct | Metodo_p | Intensidad_orientativa |
|---|---|---|---|---|---|---|---|
| Tipo de vivienda - Zona | 582.6352 | 4 | <0.0001 | 0.2999 | 0 | Chi-cuadrado asintótico | Débil |
| Zona - Barrio | 6787.5405 | 60 | 0.0001 | 0.5117 | 30 | Monte Carlo (9999 réplicas) | Fuerte |
Cuando alguna frecuencia esperada es menor que 1 o más del 20 % son menores que 5, se estima el valor p mediante Monte Carlo. La V de Cramér siempre se calcula con el estadístico de Pearson sin corrección. Los grados de libertad reportados corresponden a Pearson y no al cálculo simulado del valor p. Las etiquetas de intensidad son orientativas y dependen del contexto y de las dimensiones de la tabla. Con muestras grandes, una asociación pequeña puede resultar significativa; además, la interpretación inferencial está limitada por el origen no probabilístico de los anuncios.
El mapa muestra la ubicación de una muestra máxima de 4.000 propiedades, coloreadas según el conglomerado. La reducción de puntos mejora el rendimiento del informe HTML sin modificar los modelos, que se estimaron utilizando toda la base depurada.
datos_mapa <- vivienda_cluster |>
filter(
is.finite(longitud),
is.finite(latitud),
between(longitud, -180, 180),
between(latitud, -90, 90)
)
if (nrow(datos_mapa) > 4000) {
set.seed(2026)
datos_mapa <- slice_sample(datos_mapa, n = 4000)
}
paleta_cluster <- colorFactor(
palette = "Set2",
domain = datos_mapa$Conglomerado
)
if (nrow(datos_mapa) == 0L) {
cat("No hay coordenadas válidas para representar el mapa.")
} else if (knitr::is_html_output()) {
leaflet(datos_mapa) |>
addProviderTiles(providers$CartoDB.Positron) |>
addCircleMarkers(
lng = ~longitud,
lat = ~latitud,
radius = 4,
stroke = FALSE,
fillOpacity = 0.72,
color = ~paleta_cluster(Conglomerado),
popup = ~paste0(
"<b>Barrio:</b> ", barrio,
"<br><b>Zona:</b> ", zona,
"<br><b>Tipo:</b> ", tipo,
"<br><b>Estrato:</b> ", estrato,
"<br><b>Precio:</b> ", round(preciom, 1), " millones",
"<br><b>Área:</b> ", round(areaconst, 1), " m²",
"<br><b>Conglomerado:</b> ", Conglomerado
)
) |>
addLegend(
position = "bottomright",
pal = paleta_cluster,
values = ~Conglomerado,
title = "Conglomerado",
opacity = 1
)
} else {
print(
ggplot(datos_mapa, aes(longitud, latitud, color = Conglomerado)) +
geom_point(alpha = 0.6, size = 0.8) +
coord_quickmap() +
labs(title = "Distribución geográfica de las propiedades",
x = "Longitud", y = "Latitud") +
theme_minimal(base_size = 12)
)
}El análisis multivariado permite pasar de una colección extensa de anuncios a una representación más manejable del mercado. El PCA resume las características físicas y económicas que generan mayor diferenciación entre propiedades; el análisis de conglomerados convierte esas diferencias en segmentos con perfiles específicos; y las correspondencias permiten reconocer qué categorías territoriales, residenciales y socioeconómicas aparecen asociadas en la oferta.
La principal implicación empresarial es que el mercado no debe tratarse como un conjunto homogéneo. La valoración, la promoción y la búsqueda de oportunidades requieren criterios diferenciados según el segmento, la zona y el estrato. Los resultados ofrecen una base cuantitativa para orientar decisiones, pero deben complementarse con conocimiento local, verificación jurídica y datos reales de transacción.
Greenacre, M. (2017). Correspondence analysis in practice (3rd ed.). CRC Press.
Jolliffe, I. T., & Cadima, J. (2016). Principal component analysis: A review and recent developments. Philosophical Transactions of the Royal Society A: Mathematical, Physical and Engineering Sciences, 374(2065), 20150202. https://doi.org/10.1098/rsta.2015.0202
Kaufman, L., & Rousseeuw, P. J. (1990). Finding groups in data: An introduction to cluster analysis. Wiley.
Lê, S., Josse, J., & Husson, F. (2008). FactoMineR: An R package for multivariate analysis. Journal of Statistical Software, 25(1), 1–18. https://doi.org/10.18637/jss.v025.i01
R Core Team. (2026). R: A language and environment for statistical computing. R Foundation for Statistical Computing. https://www.R-project.org/