1 Resumen ejecutivo

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.

2 Planteamiento del problema

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.

3 Objetivos

3.1 Objetivo general

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.

3.2 Objetivos específicos

  1. Caracterizar la distribución de las variables cuantitativas y cualitativas de las propiedades.
  2. Reducir la dimensionalidad de las características numéricas mediante un análisis de componentes principales.
  3. Segmentar las viviendas en conglomerados homogéneos según sus condiciones físicas y económicas.
  4. Examinar la asociación entre el tipo de vivienda, la zona, el barrio y el estrato.
  5. Comunicar los hallazgos mediante tablas, gráficos y un mapa interactivo.

4 Preparación del entorno

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)
})

5 Base de datos

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

glimpse(datos)
## 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…

5.1 Clasificación de las variables

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")
)
Clasificación y utilización de las variables
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.

6 Calidad y limpieza de los datos

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

7 Análisis descriptivo

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"
)
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"
  )

7.1 Correlaciones

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.

8 Análisis de componentes principales

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

kable(
  cargas_pca,
  digits = 3,
  caption = "Cargas de las variables en los componentes principales"
)
Cargas de las variables en los componentes principales
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

9 Análisis de conglomerados

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"
)
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"
)
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)

9.1 Distribución por zona y estrato

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"
)
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"
)
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)

10 Análisis de correspondencias

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)
}

10.1 Correspondencia entre tipo de vivienda y zona

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

10.2 Correspondencia entre zona y barrio

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")
))

10.3 Correspondencias múltiples

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

11 Visualización geográfica

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)
  )
}

12 Hallazgos principales

  1. Después de la depuración se analizaron 6.480 propiedades.
  2. Los dos primeros componentes principales explicaron 82.94 % de la variabilidad conjunta de las características cuantitativas.
  3. El criterio de silueta identificó 2 segmentos de propiedades.
  4. El conglomerado 2 presentó la mayor mediana de precio (700 millones de pesos), mientras que el conglomerado 1 registró la menor (295 millones).
  5. La zona con mayor cantidad de anuncios fue Zona Sur con 3973 propiedades dentro de la base depurada.
  6. El tipo de vivienda más frecuente fue Apartamento con 4098 anuncios.
  7. La significancia de las asociaciones categóricas debe interpretarse junto con la V de Cramér, porque el gran tamaño de la muestra puede producir valores p pequeños aun cuando la intensidad sea limitada.

13 Recomendaciones estratégicas

  1. Diferenciar la valoración: construir rangos de precio separados por conglomerado, zona y estrato en lugar de aplicar un único promedio general.
  2. Gestionar el segmento premium: utilizar el conglomerado de mayor mediana de precio para desarrollar estrategias comerciales especializadas y evaluar propiedades con mayor área, dotación y capacidad de parqueo.
  3. Aprovechar el segmento de entrada: orientar el conglomerado de menor precio hacia compradores sensibles al presupuesto, inversiones de entrada y propiedades con posibilidad de alta rotación.
  4. Priorizar características relevantes: incorporar en los modelos de valoración las variables con mayores cargas en el PCA y mayor relación con el precio.
  5. Segmentar la comunicación comercial: diseñar campañas según las combinaciones de tipo, zona, barrio y estrato identificadas en el análisis de correspondencias.
  6. Actualizar periódicamente los modelos: repetir el análisis con información reciente para detectar cambios en precios, oferta y composición territorial.
  7. Complementar la información: añadir antigüedad, estado físico, administración, seguridad, accesibilidad, cercanía a servicios y precios reales de transacción.

14 Limitaciones

  • La base representa anuncios de oferta y no confirma el precio final de cierre de cada operación.
  • Los datos provienen de web scraping y pueden contener omisiones, republicaciones o errores de digitación.
  • La eliminación de registros incompletos puede modificar parcialmente la composición de la muestra analizada.
  • El algoritmo k-medias favorece conglomerados aproximadamente compactos y es sensible a observaciones extremas; por ello se estandarizaron las variables y se controlaron las colas de precio y área.
  • Las asociaciones y proximidades observadas no permiten establecer relaciones causales.

15 Conclusiones

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.

16 Referencias

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/