0.1 Planteamiento del problema

Una empresa inmobiliaria líder en una gran ciudad necesita comprender a fondo el mercado de vivienda urbana para tomar decisiones estratégicas de compra, venta y valoración. Contamos con una base de 8.322 registros de propiedades en oferta, obtenida por web scraping del portal OLX y contenida en el paquete paqueteMODELOS (Universidad Javeriana Cali).

En este informe desarrollo un análisis multivariado integral orientado a responder tres preguntas de negocio concretas:

  1. ¿Qué características explican en mayor medida la variación de precio y tamaño de las propiedades? → Análisis de Componentes Principales (PCA).
  2. ¿Existen segmentos naturales de oferta con perfiles diferenciados? → Análisis de Conglomerados (clustering).
  3. ¿Qué patrones de asociación existen entre tipo de vivienda, zona y barrio? → Análisis de Correspondencia.

1 1. Comprensión de los datos

Antes de aplicar cualquier técnica multivariada es indispensable conocer la estructura, calidad y limitaciones de la base.

1.1 1.1 Estructura general

# dplyr::glimpse() cumple el mismo propósito diagnóstico que str() -listar
# tipo y vista previa de cada variable- pero con una salida más legible:
# una fila por variable, tipo abreviado y los primeros valores alineados
# en una sola línea. Se prefiere aquí por claridad de presentación en un
# documento HTML orientado a un lector no necesariamente técnico.
dplyr::glimpse(vivienda)
## 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…

La base contiene 8.322 observaciones y 13 variables, que clasifico en cuatro grupos:

  • Identificación y ubicación: id, zona (categórica, 5 niveles), barrio (alta cardinalidad), longitud, latitud.
  • Características físicas: areaconst (m² construidos), parqueaderos, banios, habitaciones, piso.
  • Variables comerciales: preciom (precio en millones de pesos), estrato (ordinal, 3 a 6).
  • Tipología: tipo (Casa / Apartamento).

1.2 1.2 Diagnóstico de calidad de datos

Reviso valores faltantes (NA), consistencia y distribución de las variables clave antes de decidir cualquier tratamiento.

# sapply() aplica la misma función a cada columna del data frame y devuelve
# un vector con un resultado por columna. Aquí, is.na(x) marca cada celda
# como TRUE/FALSE según sea NA, y sum() sobre un vector lógico cuenta
# cuántos TRUE hay -es decir, cuántos NA tiene cada variable-.
na_counts <- sapply(vivienda, function(x) sum(is.na(x)))

# Construyo un data frame propio (no el objeto crudo de sapply) para poder
# darle formato de tabla con nombres de columna legibles para el lector.
na_df <- data.frame(
  Variable = names(na_counts),
  NAs = as.integer(na_counts),
  Porcentaje = round(na_counts / nrow(vivienda) * 100, 1)
) %>%
  arrange(desc(NAs))   # ordeno de mayor a menor NA para priorizar la lectura

# kable() genera la tabla en formato HTML; kable_styling() y las funciones
# de kableExtra (row_spec, column_spec) le dan estilo visual profesional
# (encabezado resaltado, texto en negrita en columnas clave).
kable(na_df,
      col.names = c("Variable", "Valores faltantes (NA)", "% del total"),
      caption = "Diagnóstico de valores faltantes por variable",
      align = c("l", "c", "c"),
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover", "condensed"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white") %>%
  column_spec(2:3, bold = TRUE)
Diagnóstico de valores faltantes por variable
Variable Valores faltantes (NA) % del total
piso piso 2638 31.7
parqueaderos parqueaderos 1605 19.3
id id 3 0.0
zona zona 3 0.0
estrato estrato 3 0.0
areaconst areaconst 3 0.0
banios banios 3 0.0
habitaciones habitaciones 3 0.0
tipo tipo 3 0.0
barrio barrio 3 0.0
longitud longitud 3 0.0
latitud latitud 3 0.0
preciom preciom 2 0.0

Observo tres patrones distintos de valores faltantes que requieren tratamientos diferentes:

  • 3 NA repetidos en casi todas las variables (id, zona, estrato, preciom, tipo, barrio, coordenadas, etc.): el patrón idéntico sugiere que no son 3 problemas independientes, sino las mismas 3 filas completamente vacías.
  • parqueaderos: 1.605 NA (19,3 % de la base). Proporción demasiado alta para ser error de captura aleatorio.
  • piso: 2.638 NA (31,7 %). No la uso en el análisis multivariado, por lo que solo la documento como limitación.

1.2.1 Verificación de las filas completamente vacías

# Filtro las filas donde "id" es NA. Si el patrón de NA observado en la
# tabla anterior corresponde efectivamente a filas vacías completas (y no
# a NAs dispersos coincidentes por azar), estas filas deberían mostrar
# NA en la mayoría o la totalidad de sus columnas.
filas_vacias <- vivienda[is.na(vivienda$id), ]

kable(filas_vacias,
      caption = "Filas con identificador faltante (residuos del web scraping)",
      booktabs = TRUE) %>%
  kable_styling(full_width = TRUE,
                bootstrap_options = c("striped", "hover", "condensed"),
                font_size = 12) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white") %>%
  scroll_box(width = "100%", height = "200px")
Filas con identificador faltante (residuos del web scraping)
id zona piso estrato preciom areaconst parqueaderos banios habitaciones tipo barrio longitud latitud
NA NA NA NA NA NA NA NA NA NA NA NA NA
NA NA NA NA NA NA NA NA NA NA NA NA NA
NA NA NA NA 330 NA NA NA NA NA NA NA NA

Dos de las tres filas no tienen ningún dato útil. La tercera conserva únicamente preciom = 330, sin zona, tipo ni área. Un solo valor numérico suelto, sin contexto, no aporta información utilizable. Conclusión: las tres filas son residuos del proceso de web scraping y las elimino sin pérdida de información relevante.

1.2.2 Verificación del patrón de valores faltantes en piso

# table(var1, var2) construye una tabla de contingencia cruzando dos
# variables. Aquí cruzo "tipo" de vivienda contra si "piso" es NA o no,
# para verificar si la ausencia de dato es estructural (p. ej. las casas
# no reportan "piso" porque conceptualmente no aplica) o simplemente
# un dato no diligenciado por el anunciante, independiente del tipo.
# as.data.frame.matrix() convierte la tabla de contingencia (una matriz)
# en un data.frame editable, necesario para renombrar columnas con kable.
tabla_piso <- as.data.frame.matrix(table(vivienda$tipo, is.na(vivienda$piso)))
colnames(tabla_piso) <- c("Piso reportado", "Piso faltante (NA)")
tabla_piso$Tipo <- rownames(tabla_piso)
tabla_piso <- tabla_piso[, c("Tipo", "Piso reportado", "Piso faltante (NA)")]

kable(tabla_piso,
      caption = "Distribución de valores faltantes de 'piso' según tipo de vivienda",
      align = "c",
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover", "condensed"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Distribución de valores faltantes de ‘piso’ según tipo de vivienda
Tipo Piso reportado Piso faltante (NA)
Apartamento 3719 1381
Casa 1965 1254

Si la ausencia de piso fuera puramente estructural (por ejemplo, porque una casa no tiene “piso” numerado), esperaría ver prácticamente el 100 % de los NA concentrados en las casas. La tabla muestra lo contrario: 1.381 apartamentos y 1.254 casas tienen piso faltante. Concluyo que el dato simplemente no fue reportado por el anunciante, independientemente del tipo de vivienda, y lo documento como limitación sin requerir imputación.

2 2. Limpieza y preparación de los datos

Con el diagnóstico completo, aplico las siguientes decisiones de limpieza, cada una justificada de forma independiente.

# ----------------------------------------------------------------------
# DECISIÓN 1: eliminar las 3 filas sin información útil (id = NA).
# Justificación: verificado en la sección anterior que son residuos del
# scraping sin contenido analítico aprovechable.
# ----------------------------------------------------------------------
vivienda_limpia <- vivienda %>%
  filter(!is.na(id))

# ----------------------------------------------------------------------
# DECISIÓN 2: imputar los NA de "parqueaderos" como 0.
# Justificación de negocio (no estadística): en anuncios de OLX, la
# ausencia de mención a parqueadero típicamente significa que el
# inmueble no lo ofrece, no que el dato se perdió al azar. Este es un
# caso de dato faltante NO ignorable (MNAR: Missing Not At Random),
# porque la razón de la ausencia está relacionada con el valor mismo
# que falta. Por eso NO se usa imputación estadística (media, mediana,
# kNN), que asumiría un mecanismo MCAR/MAR y distorsionaría la
# interpretación de negocio.
# ----------------------------------------------------------------------
vivienda_limpia <- vivienda_limpia %>%
  mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos))

# ----------------------------------------------------------------------
# DECISIÓN 3: eliminar filas con NA residual en las variables que
# efectivamente uso en el análisis multivariado (PCA/clúster requieren
# matrices completas, sin NA, para calcular distancias).
# ----------------------------------------------------------------------
vivienda_limpia <- vivienda_limpia %>%
  filter(!is.na(preciom), !is.na(areaconst), !is.na(banios),
         !is.na(habitaciones), !is.na(estrato))

# ----------------------------------------------------------------------
# DECISIÓN 4: tipificar correctamente las variables categóricas.
# "estrato" se declara factor ORDENADO porque es una escala socio-
# económica ordinal (3 < 4 < 5 < 6); "zona" y "tipo" son factores
# nominales sin orden intrínseco.
# ----------------------------------------------------------------------
vivienda_limpia <- vivienda_limpia %>%
  mutate(
    estrato = factor(estrato, ordered = TRUE),
    zona    = factor(zona),
    tipo    = factor(tipo)
  )

# Tabla resumen que cuantifica el impacto total de la limpieza sobre el
# tamaño de la muestra, para que el lector evalúe si la pérdida de datos
# compromete la representatividad del análisis.
resumen_limpieza <- data.frame(
  Concepto = c("Registros originales", "Registros tras limpieza", "Registros eliminados", "% eliminado"),
  Valor = c(
    nrow(vivienda),
    nrow(vivienda_limpia),
    nrow(vivienda) - nrow(vivienda_limpia),
    paste0(round((nrow(vivienda) - nrow(vivienda_limpia)) / nrow(vivienda) * 100, 2), " %")
  )
)

kable(resumen_limpieza,
      caption = "Resumen del proceso de limpieza",
      align = c("l", "c"),
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Resumen del proceso de limpieza
Concepto Valor
Registros originales 8322
Registros tras limpieza 8319
Registros eliminados 3
% eliminado 0.04 %

Análisis: La base final conserva 8319 de los 8322 registros originales (pérdida menor al 1 %), sin comprometer la representatividad de la muestra.

2.1 2.1 Tratamiento de valores atípicos

El PCA y el Análisis de Conglomerados se calculan sobre distancias euclidianas, por lo que son sensibles a valores extremos. Identifico los atípicos de precio antes de decidir si los conservo o elimino.

# quantile() calcula los percentiles 25, 50 y 75 (Q1, mediana, Q3).
# RIC = rango intercuartílico = Q3 - Q1, medida de dispersión robusta
# frente a valores extremos.
Q <- quantile(vivienda_limpia$preciom, c(.25, .5, .75))
RIC <- Q[3] - Q[1]

# Criterio de outlier EXTREMO (Q3 + 3*RIC), más conservador que el
# clásico de 1.5*RIC (outlier "moderado"). Justificación: con precios de
# vivienda, el criterio de 1.5*RIC marcaría como atípica casi cualquier
# propiedad de lujo, que no es un error de captura sino una categoría
# real y relevante del mercado que la empresa necesita analizar.
lim_sup <- Q[3] + 3 * RIC

# Cuento y calculo el porcentaje de propiedades por encima del umbral,
# para dimensionar el impacto de la decisión de conservarlos o no.
n_outliers <- sum(vivienda_limpia$preciom > lim_sup)
pct_outliers <- round(mean(vivienda_limpia$preciom > lim_sup) * 100, 1)

# Boxplot comparativo de precio por tipo de vivienda, con línea punteada
# marcando el umbral de atípico extremo, para visualizar cuántos puntos
# superan el criterio y en qué tipo de vivienda se concentran.
ggplot(vivienda_limpia, aes(x = tipo, y = preciom)) +
  geom_boxplot(fill = "#034A94", alpha = 0.65, outlier.color = "firebrick", outlier.size = 1.5) +
  geom_hline(yintercept = lim_sup, linetype = "dashed", color = "firebrick", linewidth = 0.8) +
  labs(
    title = "Distribución del precio por tipo de vivienda",
    subtitle = paste0("Umbral de atípico extremo (Q3 + 3×RIC): ", round(lim_sup), " millones COP"),
    x = "Tipo de vivienda",
    y = "Precio (millones COP)"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

Identifiqué 132 registros (1.6 %) por encima del umbral, presentes en ambos tipos de vivienda. Decisión: los conservo. Primero, no son errores de captura sino propiedades de lujo reales. Segundo, el objetivo del análisis es apoyar decisiones sobre todo el portafolio, incluido el segmento premium. Eliminarlos sesgaría el PCA y el clúster en contra de ese segmento. Advierto, sin embargo, que su presencia debe considerarse al interpretar los resultados, dada la sensibilidad de ambos métodos a la métrica euclidiana.

3 3. Análisis exploratorio: matriz de correlaciones

Antes de aplicar el PCA verifico que exista correlación suficiente entre las variables cuantitativas. De lo contrario, resumirlas en componentes no aportaría reducción de dimensionalidad real.

# Selecciono las 5 variables cuantitativas que usaré en PCA y clustering.
# Excluyo "estrato" (ordinal, no continua) y las coordenadas geográficas
# (que no describen características físicas/comerciales de la propiedad
# sino su ubicación, y se usan aparte en la sección 7).
vars_cuant <- vivienda_limpia %>%
  select(preciom, areaconst, parqueaderos, banios, habitaciones)

# cor() calcula la matriz de correlaciones de Pearson por defecto entre
# todas las combinaciones de pares de columnas.
matriz_cor <- cor(vars_cuant)
round(matriz_cor, 3)
##              preciom areaconst parqueaderos banios habitaciones
## preciom        1.000     0.687        0.640  0.669        0.264
## areaconst      0.687     1.000        0.482  0.648        0.517
## parqueaderos   0.640     0.482        1.000  0.523        0.199
## banios         0.669     0.648        0.523  1.000        0.590
## habitaciones   0.264     0.517        0.199  0.590        1.000
# corrplot visualiza la matriz de correlación como un mapa de calor con
# los coeficientes numéricos superpuestos, facilitando la lectura rápida
# de qué pares de variables están más o menos asociados linealmente.
# type = "upper" muestra solo el triángulo superior (la matriz es
# simétrica, así que el inferior es redundante).
corrplot(matriz_cor, method = "color", type = "upper",
         addCoef.col = "black", tl.col = "black", number.cex = 0.85,
         title = "Matriz de correlaciones — variables cuantitativas",
         mar = c(0, 0, 2, 0))

Análisis: Todas las correlaciones son positivas, en un rango de 0.199 a 0.687. La correlación más fuerte es preciomareaconst (0.687), consistente con la intuición económica de que el precio depende fuertemente del tamaño construido. La más débil es parqueaderoshabitaciones (0.199). En términos generales, habitaciones es la variable menos correlacionada con el resto del conjunto. El nivel de correlación conjunta observado es suficiente, en un examen preliminar, para justificar la aplicación de un PCA; esta impresión se confirma formalmente en la siguiente sección.

3.1 3.1 Verificación formal de idoneidad para PCA

Una inspección visual de la matriz de correlaciones es un primer indicio, pero no una prueba. El estándar metodológico para decidir si una matriz de correlaciones es “factorizable” (es decir, si tiene suficiente estructura común como para resumirse en menos dimensiones sin pérdida sustancial de información) son dos pruebas complementarias:

  • Prueba de esfericidad de Bartlett: contrasta H0: la matriz de correlaciones es igual a la matriz identidad (todas las variables son independientes entre sí, correlación = 0). Si se rechaza H0, hay evidencia de que existe correlación suficiente para factorizar.
  • Índice KMO (Kaiser-Meyer-Olkin): mide, para cada variable y en conjunto, qué proporción de la varianza es varianza común compartida (factorizable) frente a varianza específica/ruido. Escala de interpretación (Kaiser, 1974): KMO > 0.90 excelente; 0.80–0.89 meritorio; 0.70–0.79 aceptable; 0.60–0.69 mediocre; < 0.60 inaceptable (no debería aplicarse PCA/factorial).
# cortest.bartlett() requiere la matriz de correlación y el tamaño
# muestral (n) usado para calcularla, porque el estadístico de prueba
# depende de n.
bartlett_test <- cortest.bartlett(matriz_cor, n = nrow(vars_cuant))

resultados_bartlett <- data.frame(
  Estadístico = c("Chi-cuadrado (X²)", "Grados de libertad (df)", "p-valor"),
  Valor = c(
    round(bartlett_test$chisq, 2),
    bartlett_test$df,
    ifelse(bartlett_test$p.value < 2.2e-16, "< 2.2 × 10⁻¹⁶", format.pval(bartlett_test$p.value, digits = 3))
  )
)

kable(resultados_bartlett,
      caption = "Prueba de esfericidad de Bartlett",
      align = c("l", "c"),
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Prueba de esfericidad de Bartlett
Estadístico Valor
Chi-cuadrado (X²) 21138.08
Grados de libertad (df) 10
p-valor < 2.2 × 10⁻¹⁶
# KMO() devuelve el índice global (MSA global) y el índice individual
# por variable (MSA por variable), útil para detectar si alguna
# variable en particular aporta poco a la estructura factorial común.
kmo_result <- KMO(matriz_cor)

tabla_kmo <- data.frame(
  Variable = c("Global (todas las variables)", names(kmo_result$MSAi)),
  KMO = round(c(kmo_result$MSA, kmo_result$MSAi), 3)
)

kable(tabla_kmo,
      caption = "Índice KMO global y por variable (Measure of Sampling Adequacy)",
      align = c("l", "c"),
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white") %>%
  row_spec(1, bold = TRUE, background = "#EAF1FB")
Índice KMO global y por variable (Measure of Sampling Adequacy)
Variable KMO
Global (todas las variables) 0.742
preciom 0.698
areaconst 0.794
parqueaderos 0.848
banios 0.767
habitaciones 0.599

la prueba de Bartlett rechaza H0 (p < 2,2×10⁻¹⁶): existe evidencia estadísticamente contundente de que la matriz de correlaciones difiere de la identidad, es decir, las variables no son independientes entre sí. El índice KMO global se ubica en el rango “mediocre-aceptable” según la escala de Kaiser, lo cual es coherente con lo observado en la matriz de correlaciones: hay estructura común real, aunque no tan fuerte como en escenarios psicométricos clásicos (encuestas con muchos ítems altamente correlacionados). Ambas pruebas, en conjunto, respaldan formalmente la decisión de aplicar PCA, no solo por criterio visual.

4 4. Análisis de Componentes Principales

El PCA construye variables artificiales (componentes), combinaciones lineales ortogonales de las variables originales, ordenadas por varianza explicada decreciente. El objetivo es resumir las 5 variables cuantitativas en un número menor de ejes que retengan la mayor parte posible de la variabilidad del mercado.

Supuestos del método y su verificación en este análisis:

  • Variables cuantitativas continuas (o al menos de intervalo): se cumple; las 5 variables seleccionadas son numéricas.
  • Relaciones lineales entre variables: el PCA solo captura estructura lineal. La matriz de correlación de Pearson (sección 3) confirma relaciones lineales moderadas-fuertes entre las variables.
  • Correlación suficiente entre variables (factorizabilidad): verificado formalmente en 3.1 con Bartlett y KMO.
  • Sensibilidad a la escala de medición: las variables originales tienen escalas muy distintas (precio en millones de pesos frente a habitaciones en unidades). Sin estandarizar, preciom dominaría artificialmente el resultado por su magnitud, no por su importancia real. Se soluciona estandarizando (scale()) antes del cálculo.
  • Sensibilidad a valores atípicos: el PCA se basa en varianzas y covarianzas, muy sensibles a outliers. Ya se documentó en 2.1 la decisión deliberada de conservarlos por relevancia de negocio; esto debe tenerse presente al interpretar la dispersión de los componentes.
# scale() estandariza cada variable a media 0 y desviación estándar 1:
# z = (x - media) / desviación estándar. Es indispensable antes de un
# PCA cuando las variables originales están en escalas distintas.
vars_z <- scale(vars_cuant)

# prcomp() calcula los componentes principales mediante descomposición
# en valores singulares (SVD) de la matriz de datos estandarizados.
res.pca <- prcomp(vars_z)

# res.pca$sdev contiene la desviación estándar de cada componente; su
# cuadrado es la varianza explicada por ese componente. Al dividir entre
# la varianza total (suma de todas) y multiplicar por 100, obtengo el
# porcentaje de varianza explicada por cada componente.
varianza_explicada <- res.pca$sdev^2 / sum(res.pca$sdev^2) * 100

tabla_var <- data.frame(
  Componente = paste0("CP", 1:5),
  Varianza_pct = round(varianza_explicada, 2),
  # cumsum() acumula la varianza progresivamente: CP1, CP1+CP2, etc.
  # Permite decidir cuántos componentes retener según un umbral (p. ej. 80%).
  Varianza_acumulada_pct = round(cumsum(varianza_explicada), 2)
)

kable(tabla_var,
      caption = "Varianza explicada por componente principal",
      align = "c",
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Varianza explicada por componente principal
Componente Varianza_pct Varianza_acumulada_pct
CP1 62.57 62.57
CP2 18.36 80.92
CP3 8.70 89.63
CP4 6.49 96.12
CP5 3.88 100.00

Análisis: El primer componente explica por sí solo el 62,6 % de la variabilidad total, y junto al segundo alcanza el 80,9 %. Este resultado es coherente con lo observado en la matriz de correlaciones: al estar la mayoría de las variables correlacionadas entre 0.48 y 0.69, un solo eje logra capturar el movimiento conjunto de gran parte de ellas.

# fviz_eig() (de factoextra) grafica automáticamente el porcentaje de
# varianza explicada por cada componente a partir de un objeto prcomp,
# con etiquetas numéricas sobre cada barra (addlabels = TRUE).
fviz_eig(res.pca, addlabels = TRUE, ylim = c(0, 70)) +
  labs(title = "Gráfico de sedimentación (Scree Plot)",
       x = "Componente principal",
       y = "% de varianza explicada") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

Con base en el criterio de varianza acumulada (> 80 %) y en la clara inflexión del gráfico de sedimentación tras el segundo componente, retengo CP1 y CP2 para la interpretación y visualización posteriores.

4.1 4.2 Interpretación de los componentes retenidos

# res.pca$rotation es la matriz de "cargas" (loadings): el peso de cada
# variable original dentro de cada componente. El signo indica la
# dirección de la relación y la magnitud, la fuerza de la contribución.
kable(round(res.pca$rotation, 3),
      caption = "Cargas (loadings) de las variables en cada componente",
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Cargas (loadings) de las variables en cada componente
PC1 PC2 PC3 PC4 PC5
preciom 0.479 0.360 -0.359 0.264 0.666
areaconst 0.484 -0.085 -0.562 -0.538 -0.392
parqueaderos 0.408 0.520 0.661 -0.346 -0.081
banios 0.496 -0.157 0.121 0.695 -0.482
habitaciones 0.352 -0.754 0.323 -0.196 0.406

Componente 1 (62,6 % de la varianza): todas las variables cargan con signo positivo y magnitud similar (entre 0.35 y 0.50), sin que ninguna domine sobre las demás. Esto indica que CP1 es un eje de “categoría” o tamaño-calidad general de la propiedad: valores altos de CP1 corresponden a propiedades simultáneamente más grandes, más caras, con más parqueaderos y más baños.

Componente 2 (18,4 % de la varianza): habitaciones carga con signo fuertemente negativo (−0.754), en contraste con parqueaderos (+0.520) y preciom (+0.360). CP2 separa dos perfiles que CP1 no distingue: propiedades con muchas habitaciones pero relativamente pocos parqueaderos y precio más bajo (valores negativos de CP2 — típico de vivienda familiar amplia) frente a propiedades con parqueadero y precio alto pero pocas habitaciones (valores positivos de CP2 — típico de vivienda unipersonal o de pareja de alto valor).

# fviz_pca_var() grafica el "círculo de correlaciones": cada variable se
# representa como un vector cuya dirección indica cómo se relaciona con
# los dos componentes graficados, y cuya longitud (proximidad al borde
# del círculo unitario) indica qué tan bien está representada esa
# variable en el plano CP1-CP2. col.var = "contrib" colorea los vectores
# según su contribución total a la formación de los dos componentes.
fviz_pca_var(res.pca, col.var = "contrib",
             gradient.cols = c("#FF7F00", "#034D94"), repel = TRUE) +
  labs(title = "Círculo de correlaciones: variables en el plano CP1-CP2") +
  theme_minimal(base_size = 12)

El círculo de correlaciones confirma visualmente la lectura anterior: habitaciones apunta en una dirección claramente distinta (hacia abajo) al resto de las variables, que se agrupan apuntando hacia arriba-derecha.

# fviz_pca_ind() grafica cada observación (cada propiedad) como un punto
# en el plano CP1-CP2. col.ind colorea los puntos según una variable
# externa (tipo de vivienda, que no participó en el cálculo del PCA),
# lo que permite verificar si esa variable categórica se relaciona con
# la estructura numérica encontrada. addEllipses dibuja una elipse de
# concentración por grupo.
fviz_pca_ind(res.pca, geom = "point", alpha.ind = 0.18,
             col.ind = vivienda_limpia$tipo,
             palette = c("#034A94", "#E7B800"), addEllipses = TRUE,
             legend.title = "Tipo") +
  labs(title = "Propiedades individuales en el plano CP1-CP2, por tipo de vivienda") +
  theme_minimal(base_size = 12)

Casas y apartamentos se superponen parcialmente en el plano, pero las casas tienden a desplazarse hacia valores más altos de CP1 y más negativos de CP2, consistente con tener en promedio mayor área construida y más habitaciones.

5 5. Análisis de Conglomerados

El objetivo es segmentar las 8319 propiedades en grupos internamente homogéneos y diferenciados entre sí, usando las mismas 5 variables estandarizadas del PCA. Dado el tamaño de la muestra, empleo k-means (método no jerárquico) en lugar de un método jerárquico clásico: un dendrograma completo sobre más de 8.000 observaciones es computacionalmente costoso e ilegible visualmente.

5.1 5.1 Supuestos de k-means y su verificación en este análisis:

  • Distancia euclidiana como métrica de similitud: k-means minimiza la suma de distancias euclidianas al cuadrado dentro de cada clúster. Esto implica asumir implícitamente que los clústeres son aproximadamente esféricos y de varianza similar en cada dimensión; el algoritmo no detecta bien formas alargadas o de densidad muy distinta entre grupos.
  • Variables en la misma escala: se cumple, porque se usa vars_z (variables ya estandarizadas), evitando que preciom (en millones) domine el cálculo de distancias frente a habitaciones (en unidades).
  • Sensibilidad a valores atípicos: al igual que en el PCA, los outliers de precio conservados deliberadamente (sección 2.1) pueden “estirar” alguno de los clústeres o formar un grupo propio; esto se confirma más adelante al observar que el clúster premium concentra precisamente esas propiedades.
  • k debe fijarse de antemano: k-means no determina el número óptimo de grupos por sí mismo; requiere un criterio externo, que se desarrolla en la sección 5.2.
  • Óptimo local, no global: el algoritmo puede converger a una solución subóptima según los centroides iniciales; se mitiga con nstart (múltiples inicios aleatorios, conservando la mejor solución) y fijando semilla (set.seed) para reproducibilidad.

5.2 5.2 Selección del número de clústeres

Combino el método del codo (criterio visual sobre la suma de cuadrados intra-clúster) con el coeficiente de Silhouette (criterio cuantitativo sobre la calidad de separación de los grupos).

# set.seed() fija la semilla del generador de números aleatorios: dado
# que k-means depende de centroides iniciales aleatorios, fijar la
# semilla garantiza que el análisis sea exactamente reproducible.
set.seed(1234)

# Para cada valor de k entre 2 y 8, ejecuto kmeans() con nstart = 10
# (10 inicios aleatorios distintos, conservando automáticamente la
# solución con menor suma de cuadrados intra-clúster de las 10) y
# extraigo tot.withinss (WSS total: suma de cuadrados dentro de los
# clústeres, medida de qué tan compactos son los grupos resultantes).
wss <- sapply(2:8, function(k) kmeans(vars_z, centers = k, nstart = 10)$tot.withinss)

ggplot(data.frame(k = 2:8, wss = wss), aes(k, wss)) +
  geom_line(color = "#034A94", linewidth = 1) +
  geom_point(size = 3, color = "#034A94") +
  labs(title = "Método del codo",
       x = "Número de clústeres (k)",
       y = "Suma de cuadrados dentro del clúster (WSS)") +
  scale_x_continuous(breaks = 2:8) +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

La curva desciende marcadamente hasta k = 4 y se aplana progresivamente a partir de ahí: un primer indicio de que 4 grupos capturan la mayor parte de la estructura relevante de los datos.

# El coeficiente de Silhouette exacto requiere calcular la matriz de
# distancias entre TODOS los pares de observaciones; con ~8.300
# registros esto implicaría ~34 millones de distancias, computacio-
# nalmente costoso para un documento que debe recalcularse en cada
# knit. Se estima sobre una muestra aleatoria de 1.000 registros (con
# semilla fija para reproducibilidad), tamaño suficiente para una
# estimación confiable del promedio poblacional.
set.seed(99)
idx_muestra <- sample(1:nrow(vars_z), 1000)
d_muestra <- dist(vars_z[idx_muestra, ])

# Para cada k de 2 a 6: ajusto k-means sobre la MISMA muestra de 1.000
# observaciones usadas para calcular la matriz de distancias (deben
# coincidir), calculo el objeto silhouette() por observación y tomo el
# promedio (columna 3 del objeto silhouette = ancho de silueta "sil_width").
sil_avg <- sapply(2:6, function(k) {
  km <- kmeans(vars_z[idx_muestra, ], centers = k, nstart = 25)
  mean(silhouette(km$cluster, d_muestra)[, 3])
})

tabla_sil <- data.frame(
  k = 2:6,
  Silhouette_promedio = round(sil_avg, 3)
)

kable(tabla_sil,
      caption = "Coeficiente de Silhouette promedio (muestra de 1.000 observaciones)",
      align = "c",
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Coeficiente de Silhouette promedio (muestra de 1.000 observaciones)
k Silhouette_promedio
2 0.470
3 0.355
4 0.347
5 0.349
6 0.275

Interpretación según la escala de Kaufman & Rousseeuw (> 0.70: estructura fuerte; 0.51–0.70: razonable; 0.26–0.50: débil pero interpretable; < 0.25: sin estructura real): k = 2 obtiene el mayor Silhouette (0.470), pero representa una segmentación casi trivial (separación “barato vs. caro”) de poco valor estratégico. Los valores de k = 3, 4 y 5 son prácticamente equivalentes entre sí (0.355, 0.347 y 0.349 respectivamente), todos dentro del rango de estructura débil-interpretable.

Decisión: selecciono k = 4. Justificación: k = 4 obtiene el mismo desempeño estadístico que k = 3 y k = 5, pero ofrece mayor capacidad de segmentación para el negocio que k = 3 (demasiado agregado) con menor complejidad interpretativa que k = 5.

5.3 5.3 Segmentación final (k = 4)

# Misma semilla usada en la exploración del codo, para que el clúster
# final sea exactamente reproducible y consistente con el análisis
# exploratorio previo.
set.seed(1234)
km4 <- kmeans(vars_z, centers = 4, nstart = 25)

# Añado la asignación de clúster de cada propiedad como nueva columna,
# convertida a factor porque el número de clúster es una etiqueta
# categórica (no tiene sentido aritmético sumar o promediar "clústeres").
vivienda_limpia$cluster <- factor(km4$cluster)

# Calculo el perfil promedio de cada clúster SOBRE LA ESCALA ORIGINAL
# (variables sin estandarizar) para que la interpretación tenga
# unidades de negocio directamente interpretables (millones de pesos,
# metros cuadrados), en vez de puntuaciones z sin significado intuitivo.
perfil <- vivienda_limpia %>%
  group_by(cluster) %>%
  summarise(
    n                 = n(),
    precio_prom       = round(mean(preciom)),
    area_prom         = round(mean(areaconst)),
    parqueaderos_prom = round(mean(parqueaderos), 1),
    banios_prom       = round(mean(banios), 1),
    habitaciones_prom = round(mean(habitaciones), 1),
    .groups = "drop"
  )

kable(perfil,
      col.names = c("Clúster", "n", "Precio prom. (M$)", "Área prom. (m²)",
                    "Parqueaderos prom.", "Baños prom.", "Habitaciones prom."),
      caption = "Perfil promedio de cada segmento (k = 4) — escala original",
      align = "c",
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover", "condensed"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white") %>%
  column_spec(1, bold = TRUE)
Perfil promedio de cada segmento (k = 4) — escala original
Clúster n Precio prom. (M$) Área prom. (m²) Parqueaderos prom. Baños prom. Habitaciones prom.
1 862 1149 419 3.8 5.1 4.4
2 4160 229 90 0.8 2.1 2.9
3 759 472 319 1.1 4.6 6.9
4 2538 515 189 1.9 3.7 3.6

Análisis de los segmentos:

Clúster n Precio prom. Área prom. Perfil observado Interpretación de negocio
1 862 $1.149 M 419 m² Todos los indicadores altos (3.8 parqueaderos, 5.1 baños) Segmento premium / lujo
2 4.160 $229 M 90 m² Todos los indicadores bajos (0.8 parqueaderos, 2.1 baños) Segmento económico masivo — la mitad del mercado
3 759 $472 M 319 m² Precio menor que el clúster 4 pese a mayor área; muchas habitaciones Casas familiares grandes de bajo valor relativo por m²
4 2.538 $515 M 189 m² Valores intermedios en todos los indicadores Segmento medio

El clúster 3 constituye el hallazgo más relevante del análisis: propiedades con mayor área construida que el clúster 4 pero menor precio promedio, combinado con un número inusualmente alto de habitaciones. Este patrón confirma, a nivel de segmentación, lo que ya anticipaba el componente CP2 en el PCA. La coincidencia entre dos técnicas multivariadas independientes (PCA y clúster) que llegan a la misma conclusión refuerza la validez del hallazgo.

5.4 5.4 Validación estadística de las diferencias entre clústeres

La tabla de perfiles muestra medias distintas entre clústeres, pero eso por sí solo no confirma que la diferencia sea estadísticamente significativa y no producto del azar muestral. Con un tamaño de muestra grande (n > 8.000) casi cualquier diferencia resulta “significativa” en sentido estadístico estricto, así que complemento la prueba de hipótesis con una medida de tamaño del efecto (η², eta cuadrado), que cuantifica qué proporción de la variabilidad total de cada variable es explicada por la pertenencia al clúster.

# Ajusto un modelo ANOVA de una vía para cada variable cuantitativa,
# usando "cluster" como factor de agrupación. H0: las medias de la
# variable son iguales en los 4 clústeres. summary(aov(...)) entrega el
# estadístico F y el p-valor de la prueba.
variables_a_probar <- c("preciom", "areaconst", "parqueaderos", "banios", "habitaciones")

resultados_anova <- lapply(variables_a_probar, function(v) {
  formula_v <- as.formula(paste(v, "~ cluster"))
  modelo <- aov(formula_v, data = vivienda_limpia)
  resumen <- summary(modelo)[[1]]

  # Eta cuadrado (η²) = suma de cuadrados entre grupos / suma de
  # cuadrados total. Se interpreta con la convención de Cohen (1988):
  # 0.01 efecto pequeño, 0.06 efecto mediano, 0.14 o más efecto grande.
  eta2 <- resumen["cluster", "Sum Sq"] / sum(resumen[, "Sum Sq"])

  data.frame(
    Variable = v,
    F_estadistico = round(resumen["cluster", "F value"], 1),
    p_valor = ifelse(resumen["cluster", "Pr(>F)"] < 2.2e-16,
                      "< 2.2 × 10⁻¹⁶",
                      format.pval(resumen["cluster", "Pr(>F)"], digits = 3)),
    Eta_cuadrado = round(eta2, 3)
  )
})

tabla_anova <- do.call(rbind, resultados_anova)

kable(tabla_anova,
      col.names = c("Variable", "F", "p-valor", "η² (tamaño del efecto)"),
      caption = "ANOVA de una vía: diferencias entre clústeres por variable",
      align = "c",
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
ANOVA de una vía: diferencias entre clústeres por variable
Variable F p-valor η² (tamaño del efecto)
preciom 6629.1 < 2.2 × 10⁻¹⁶ 0.705
areaconst 3731.6 < 2.2 × 10⁻¹⁶ 0.574
parqueaderos 3254.5 < 2.2 × 10⁻¹⁶ 0.540
banios 4807.6 < 2.2 × 10⁻¹⁶ 0.634
habitaciones 4322.8 < 2.2 × 10⁻¹⁶ 0.609

Las cinco variables muestran diferencias de medias estadísticamente significativas entre clústeres (p < 0,001 en todos los casos), y —más importante que la significancia por sí sola dado el tamaño muestral— los valores de η² son grandes (todos por encima de 0.14 según la convención de Cohen), lo que indica que la pertenencia al clúster explica una proporción sustancial de la varianza de cada variable, no solo una diferencia estadísticamente detectable pero irrelevante en magnitud. Esto respalda que los 4 segmentos son sustantivamente diferenciados, no solo un artefacto del algoritmo.

Nota metodológica: ANOVA asume normalidad de residuos y homogeneidad de varianzas entre grupos, supuestos que probablemente se violan aquí dado que preciom y areaconst están sesgadas a la derecha (por los outliers de lujo conservados deliberadamente). Con n > 800 por grupo, el estadístico F es razonablemente robusto a violaciones moderadas de normalidad por el Teorema Central del Límite, pero para un reporte más riguroso se recomendaría contrastar con la alternativa no paramétrica de Kruskal-Wallis, que no asume normalidad:

# kruskal.test() es la alternativa no paramétrica a ANOVA de una vía:
# contrasta si las distribuciones (no solo las medias) difieren entre
# grupos, usando rangos en vez de valores originales, sin asumir
# normalidad ni homogeneidad de varianzas.
resultados_kruskal <- lapply(variables_a_probar, function(v) {
  formula_v <- as.formula(paste(v, "~ cluster"))
  test <- kruskal.test(formula_v, data = vivienda_limpia)
  data.frame(
    Variable = v,
    Chi_cuadrado = round(test$statistic, 1),
    p_valor = ifelse(test$p.value < 2.2e-16, "< 2.2 × 10⁻¹⁶", format.pval(test$p.value, digits = 3))
  )
})

tabla_kruskal <- do.call(rbind, resultados_kruskal)

kable(tabla_kruskal,
      col.names = c("Variable", "Chi-cuadrado (H)", "p-valor"),
      caption = "Prueba de Kruskal-Wallis (alternativa no paramétrica a ANOVA)",
      align = "c",
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Prueba de Kruskal-Wallis (alternativa no paramétrica a ANOVA)
Variable Chi-cuadrado (H) p-valor
preciom 5513.2 < 2.2 × 10⁻¹⁶
areaconst 5617.8 < 2.2 × 10⁻¹⁶
parqueaderos 4186.9 < 2.2 × 10⁻¹⁶
banios 5807.1 < 2.2 × 10⁻¹⁶
habitaciones 3994.1 < 2.2 × 10⁻¹⁶

Kruskal-Wallis confirma la misma conclusión que ANOVA (p < 0,001 en las cinco variables), sin depender del supuesto de normalidad. La coincidencia entre ambas pruebas da mayor solidez metodológica a la afirmación de que los clústeres son grupos genuinamente diferenciados.

# fviz_cluster() proyecta las observaciones sobre el plano de las dos
# primeras componentes principales de "data" (aquí, vars_z) y las
# colorea según la asignación de clúster del objeto kmeans (km4),
# permitiendo visualizar la separación de los grupos en dos dimensiones
# aunque la segmentación se calculó sobre las 5 variables originales.
fviz_cluster(km4, data = vars_z, geom = "point", alpha = 0.18,
             palette = "jco", ellipse.type = "convex",
             ggtheme = theme_minimal()) +
  labs(title = "Segmentos de propiedades sobre el plano CP1-CP2") +
  theme(plot.title = element_text(face = "bold"))

5.4.1 Perfil geográfico y tipológico de los segmentos

# Tablas de contingencia simples que cruzan la asignación de clúster con
# variables categóricas externas (zona, tipo) que NO participaron en el
# cálculo del k-means, para verificar si los segmentos numéricos tienen
# también un correlato geográfico y tipológico interpretable.
cat("=== Distribución de clústeres por zona ===\n")
## === Distribución de clústeres por zona ===
print(table(vivienda_limpia$cluster, vivienda_limpia$zona))
##    
##     Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
##   1           2         83        250            2      525
##   2          64       1171        306          187     2432
##   3          37        182         47          136      357
##   4          21        484        595           26     1412
cat("\n=== Distribución de clústeres por tipo de vivienda ===\n")
## 
## === Distribución de clústeres por tipo de vivienda ===
print(table(vivienda_limpia$cluster, vivienda_limpia$tipo))
##    
##     Apartamento Casa
##   1         253  609
##   2        3410  750
##   3          17  742
##   4        1420 1118

El segmento premium (clúster 1) presenta una concentración geográfica marcada: aproximadamente el 90 % de sus propiedades están en Zona Sur y Zona Oeste. Es además predominantemente de casas (alrededor del 71 %). El clúster 3 (casas familiares grandes) es casi exclusivamente un fenómeno de casas (97,8 %), coherente con que un apartamento raramente alcanza 6–7 habitaciones.

6 6. Análisis de Correspondencia

El Análisis de Correspondencia (AC) examina la asociación entre variables categóricas sin asumir una relación lineal, ubicando las categorías de cada variable en un plano factorial según su similitud de comportamiento conjunto. Lo aplico a dos pares de variables: tipo de vivienda × zona, y zona × barrio.

6.1 6.1 Supuestos del método y su verificación:

  • Variables estrictamente categóricas (nominales): se cumple para tipo, zona y barrio (agregado).
  • La tabla de contingencia debe reflejar una asociación real, no ruido: se verifica mediante la prueba chi-cuadrado de independencia antes de interpretar el mapa factorial (secciones 6.2 y 6.3).
  • Frecuencias esperadas suficientes por celda: la aproximación chi-cuadrado a la distribución teórica es confiable cuando las frecuencias esperadas bajo H0 no son demasiado bajas (regla práctica habitual: no más del 20 % de celdas con frecuencia esperada < 5, y ninguna celda con frecuencia esperada < 1). Con categorías de baja frecuencia (como puede ocurrir en la categoría “Otros” agregada, o en zonas poco representadas), este supuesto no puede darse por sentado y se verifica explícitamente antes de interpretar la prueba.
  • AC usa distancia chi-cuadrado, sensible a categorías raras: categorías con muy pocas observaciones pueden distorsionar desproporcionadamente el mapa factorial. Es la razón metodológica detrás de agregar los barrios de baja frecuencia en la categoría “Otros” (sección 6.3), no solo una decisión de legibilidad visual.

6.2 6.2 Tipo de vivienda × Zona

Antes de interpretar el mapa factorial, verifico mediante una prueba de independencia si existe asociación estadísticamente significativa entre las variables, y confirmo que la prueba sea metodológicamente confiable dado el tamaño de las celdas.

# table(var1, var2) construye la tabla de contingencia cruzando tipo de
# vivienda (filas) y zona (columnas): cada celda cuenta cuántas
# propiedades caen en esa combinación específica.
tabla1 <- table(vivienda_limpia$tipo, vivienda_limpia$zona)

kable(as.data.frame.matrix(tabla1),
      caption = "Tabla de contingencia: Tipo de vivienda × Zona",
      booktabs = TRUE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover", "condensed"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Tabla de contingencia: Tipo de vivienda × Zona
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 24 1198 1029 62 2787
Casa 100 722 169 289 1939
# chisq.test() contrasta H0: las dos variables categóricas son
# independientes, comparando las frecuencias observadas en la tabla
# contra las frecuencias que se esperarían si no hubiera asociación
# (calculadas a partir de los totales marginales de fila y columna).
chi <- chisq.test(tabla1)

resultados_chi <- data.frame(
  Estadístico = c("Chi-cuadrado (X²)", "Grados de libertad (df)", "p-valor"),
  Valor = c(
    round(chi$statistic, 2),
    chi$parameter,
    ifelse(chi$p.value < 2.2e-16, "< 2.2 × 10⁻¹⁶", format.pval(chi$p.value, digits = 3))
  )
)

kable(resultados_chi,
      caption = "Prueba de independencia Chi-cuadrado (Tipo × Zona)",
      align = c("l", "c"),
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white") %>%
  column_spec(2, bold = TRUE)
Prueba de independencia Chi-cuadrado (Tipo × Zona)
Estadístico Valor
Chi-cuadrado (X²) 690.93
Grados de libertad (df) 4
p-valor < 2.2 × 10⁻¹⁶
# Verificación del supuesto de frecuencias esperadas suficientes:
# chi$expected contiene la matriz de frecuencias esperadas bajo H0.
# Si una proporción alta de celdas tiene frecuencia esperada < 5, la
# aproximación chi-cuadrado pierde precisión y el p-valor reportado
# podría no ser confiable.
pct_celdas_bajas <- round(mean(chi$expected < 5) * 100, 1)

Con p-valor < 2.2×10⁻¹⁶ (muy inferior a α = 0.05) rechazo H0: existe asociación estadísticamente significativa entre tipo de vivienda y zona. Verifico además el supuesto de frecuencias esperadas suficientes: 0% de las celdas de la tabla tienen frecuencia esperada menor a 5, muy por debajo del umbral problemático (20 %), por lo que la aproximación chi-cuadrado es confiable en este caso.

# CA() (de FactoMineR) ejecuta el Análisis de Correspondencia sobre la
# tabla de contingencia. graph = FALSE evita que la función genere
# automáticamente su gráfico por defecto (poco estilizado); se
# construyen visualizaciones personalizadas en los chunks siguientes.
ca1 <- CA(tabla1, graph = FALSE)

# ca1$eig contiene, por cada dimensión: el autovalor (inercia explicada
# en unidades absolutas), el % de varianza explicada, y el % acumulado.
# Con una tabla de 2 filas x 5 columnas, el número máximo de dimensiones
# no triviales es min(2-1, 5-1) = 1, de ahí que solo exista Dim 1.
eig_ca1 <- data.frame(
  Dimensión = paste0("Dim ", 1:nrow(ca1$eig)),
  Autovalor = round(ca1$eig[, 1], 5),
  `Porcentaje de varianza` = round(ca1$eig[, 2], 1),
  `Porcentaje acumulado` = round(ca1$eig[, 3], 1),
  check.names = FALSE
)

kable(eig_ca1,
      caption = "Autovalores del Análisis de Correspondencia (Tipo × Zona)",
      align = "c",
      booktabs = TRUE,
      row.names = FALSE) %>%
  kable_styling(full_width = FALSE,
                bootstrap_options = c("striped", "hover"),
                font_size = 13) %>%
  row_spec(0, bold = TRUE, background = "#034A94", color = "white")
Autovalores del Análisis de Correspondencia (Tipo × Zona)
Dimensión Autovalor Porcentaje de varianza Porcentaje acumulado
Dim 1 0.08305 100 100

La única dimensión disponible explica el 100 % de la asociación entre las variables (por construcción, dado que min(filas-1, columnas-1) = min(1, 4) = 1).

# Con una sola dimensión disponible, el biplot 2D estándar de AC no
# aplica (requeriría al menos 2 dimensiones). Construyo en su lugar una
# visualización 1D: extraigo las coordenadas de fila (tipo) y columna
# (zona) sobre la única dimensión disponible y las grafico en una recta
# numérica, coloreando por tipo de variable (fila vs columna).
coords_ca1 <- rbind(
  data.frame(categoria = names(ca1$row$coord),
             dim1 = as.numeric(ca1$row$coord),
             variable = "Tipo"),
  data.frame(categoria = rownames(ca1$col$coord),
             dim1 = as.numeric(ca1$col$coord[, 1]),
             variable = "Zona")
)

ggplot(coords_ca1, aes(x = dim1, y = variable, label = categoria, color = variable)) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "grey60") +
  geom_point(size = 3.5) +
  # ggrepel::geom_text_repel() coloca las etiquetas de texto evitando que
  # se superpongan entre sí, útil cuando varios puntos quedan cerca.
  ggrepel::geom_text_repel(size = 3.8, show.legend = FALSE) +
  labs(title = "Análisis de Correspondencia: Tipo de vivienda × Zona",
       subtitle = "Dimensión 1 (explica el 100 % de la asociación)",
       x = "Coordenada Dimensión 1",
       y = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold"))

En un AC, categorías del mismo signo (mismo lado del cero) están asociadas positivamente entre sí. El mapa muestra que Casa se asocia con Zona Oriente y Zona Centro, mientras Apartamento se asocia con Zona Oeste. Zona Norte y Zona Sur permanecen cerca del origen, sin inclinación fuerte hacia ningún tipo de vivienda en particular.

6.3 6.3 Zona × Barrio

Con 437 barrios distintos registrados en la base, un AC completo resultaría ilegible y, adicionalmente, muchas categorías tendrían frecuencias muy bajas, violando el supuesto de estabilidad de la distancia chi-cuadrado mencionado en 6.1. Agrupo los 15 barrios con mayor número de propiedades y el resto lo etiqueto como “Otros”, conservando interpretabilidad sin perder representatividad.

# table() ordenado de mayor a menor frecuencia identifica los 15
# barrios con más propiedades registradas.
top_barrios <- names(sort(table(vivienda_limpia$barrio), decreasing = TRUE))[1:15]

# Creo una nueva variable donde los barrios fuera del top-15 se
# consolidan en la categoría "Otros", reduciendo la cardinalidad de 437
# a 16 niveles manejables para el AC.
vivienda_limpia$barrio_ag <- ifelse(vivienda_limpia$barrio %in% top_barrios,
                                     vivienda_limpia$barrio, "Otros")

tabla2 <- table(vivienda_limpia$zona, vivienda_limpia$barrio_ag)
chi2 <- chisq.test(tabla2)
chi2
## 
##  Pearson's Chi-squared test
## 
## data:  tabla2
## X-squared = 8502.9, df = 60, p-value < 2.2e-16
# Verificación del supuesto de frecuencias esperadas: con una tabla de
# 5 x 16 = 80 celdas, es más probable encontrar celdas con frecuencia
# esperada baja que en la tabla 2x5 anterior. Cuantifico explícitamente
# qué porcentaje de celdas incumple el umbral convencional de 5.
pct_celdas_bajas2 <- round(mean(chi2$expected < 5) * 100, 1)
n_celdas_bajas2 <- sum(chi2$expected < 5)

Nuevamente rechazo H0 de independencia (p < 2.2×10⁻¹⁶). Este resultado es en gran medida esperable —un barrio pertenece físicamente a una única zona—, por lo que funciona más como una validación de consistencia de los datos que como un hallazgo de negocio sorprendente.

Verificación del supuesto de frecuencias esperadas: 12 de las 80 celdas de la tabla (15%) tienen frecuencia esperada menor a 5. Esto está por debajo del umbral convencional del 20 %, por lo que la aproximación chi-cuadrado sigue siendo confiable pese al mayor número de celdas de la tabla ampliada.

ca2 <- CA(tabla2, graph = FALSE)

# Con "zona" (5 niveles) y "barrio_ag" (16 niveles), el número máximo
# de dimensiones no triviales es min(5-1, 16-1) = min(4, 15) = 4, por
# lo que aquí sí es posible construir un biplot clásico de 2 ejes
# (a diferencia del caso Tipo x Zona con una sola dimensión disponible).
round(ca2$eig, 3)
##       eigenvalue percentage of variance cumulative percentage of variance
## dim 1      0.554                 54.250                            54.250
## dim 2      0.417                 40.755                            95.004
## dim 3      0.051                  4.992                            99.996
## dim 4      0.000                  0.004                           100.000

Los dos primeros ejes explican conjuntamente el 95,0 % de la asociación total (54,25 % + 40,76 %), lo que indica que la estructura real de la asociación zona-barrio es efectivamente bidimensional.

# fviz_ca_biplot() grafica simultáneamente las categorías de fila
# (zona) y de columna (barrio_ag) sobre las dos primeras dimensiones
# del AC. La proximidad entre un punto de zona y un punto de barrio en
# el plano indica una asociación fuerte entre ambas categorías.
fviz_ca_biplot(ca2, repel = TRUE, labelsize = 3.2) +
  labs(title = "Análisis de Correspondencia: Zona × Barrio (top 15)") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))

El mapa evidencia agrupaciones geográficas claramente delimitadas: barrios como Valle del Lili, Pance, Ciudad Jardín y La Hacienda se agrupan junto a Zona Sur; barrios como Aguacatal, Normandía y Santa Teresita se agrupan junto a Zona Oeste, en un cuadrante opuesto del plano. Zona Centro y Zona Oriente aparecen próximas al origen, junto a la categoría “Otros”. La coherencia entre la ubicación esperada de estos barrios y su posición en el mapa factorial constituye una validación adicional de la calidad de la variable zona en la base de datos.

7 7. Visualización geográfica complementaria

Como cierre visual, proyecto las coordenadas geográficas reales de cada propiedad, coloreadas según el segmento de clúster obtenido, para verificar si los patrones estadísticos encontrados tienen también correlato espacial directo.

# Uso las coordenadas GPS (longitud, latitud) directamente como ejes x/y
# del gráfico -un "mapa" simplificado sin capa cartográfica de fondo,
# suficiente para visualizar la distribución espacial relativa de los
# clústeres-. coord_fixed() fuerza una relación de aspecto 1:1 entre los
# ejes, evitando que ggplot2 distorsione la proporción entre longitud y
# latitud (lo cual deformaría visualmente la forma real de la ciudad).
ggplot(vivienda_limpia, aes(x = longitud, y = latitud, color = cluster)) +
  geom_point(alpha = 0.28, size = 0.9) +
  scale_color_brewer(palette = "Set1", name = "Clúster") +
  labs(title = "Distribución geográfica de los segmentos de vivienda",
       x = "Longitud",
       y = "Latitud") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold")) +
  coord_fixed()

El mapa confirma visualmente lo ya identificado en la tabla clúster × zona: el segmento premium (clúster 1) se concentra en dos bloques geográficos compactos, mientras el segmento económico (clúster 2) se dispersa de forma prácticamente uniforme por toda la mancha urbana. Esta confirmación —obtenida de forma independiente a través de cuatro técnicas distintas (PCA, clúster, correspondencia y visualización geográfica directa)— refuerza la robustez del hallazgo.

8 8. Conclusiones

8.1 8.1 Hallazgos clave

  1. Dos componentes principales resumen el 80,9 % de la variabilidad conjunta de precio, área, parqueaderos, baños y habitaciones (validado formalmente con KMO y la prueba de esfericidad de Bartlett antes de aplicar el PCA). CP1 (62,6 %) funciona como un índice de “tamaño/calidad general” de la propiedad; CP2 (18,4 %) distingue viviendas con muchas habitaciones pero bajo precio/parqueadero de viviendas caras con parqueadero pero pocas habitaciones.

  2. El mercado se segmenta en 4 perfiles diferenciados (k = 4, validado mediante método del codo, coeficiente de Silhouette, y confirmado estadísticamente con ANOVA y Kruskal-Wallis, ambos con p < 0,001 y tamaños de efecto grandes en las cinco variables):

    • Premium (≈10,4 %, concentrado geográficamente en Zona Sur/Oeste)
    • Económico masivo (≈50,0 %)
    • Casas familiares grandes de bajo valor relativo por m² (≈9,1 %)
    • Segmento medio (≈30,5 %)
  3. Tipo de vivienda, zona y barrio están estadísticamente asociados entre sí (todas las pruebas chi-cuadrado con p < 0.001, verificadas además en su idoneidad mediante el porcentaje de celdas con frecuencia esperada baja). La asociación zona-barrio (95,0 % de varianza explicada en 2 dimensiones) valida adicionalmente la consistencia geográfica de la base de datos.

8.2 8.2 Recomendaciones estratégicas

  • Diferenciación comercial por segmento: priorizar volumen de transacciones en el segmento económico (clúster 2, la mitad del mercado) y margen por operación en el segmento premium (clúster 1), aprovechando que ambos están geográfica y tipológicamente bien delimitados.

  • Revisión del clúster 3: las casas familiares grandes de bajo valor relativo por m² constituyen un segmento de posible oportunidad. Subdivisión, remodelación o reposicionamiento de precio ameritan un análisis de factibilidad adicional.

  • Segmentación geográfica de campañas: dirigir publicidad diferenciada por zona (Sur/Oeste para el segmento premium, resto de la ciudad para el económico) en lugar de campañas genéricas de cobertura total.

8.3 8.3 Limitaciones del análisis

  • El 19,3 % de datos faltantes en parqueaderos se imputó como 0 bajo un supuesto de negocio explícito (ausencia de mención = sin parqueadero). Si esta interpretación resultara incorrecta, la segmentación de los clústeres 1 y 4 podría estar sesgada.

  • Los valores atípicos de precio (≈1,6 % de la muestra) se conservaron deliberadamente. Un análisis de sensibilidad con un método robusto a atípicos (por ejemplo PAM — Partitioning Around Medoids) constituye una extensión metodológica válida para trabajo futuro.

  • El coeficiente de Silhouette de ≈0.35 obtenido en k = 4 corresponde a una estructura de clúster débil-a-moderada según la escala de Kaufman & Rousseeuw, no a fronteras naturales nítidas entre grupos. Resultado esperable dada la naturaleza continua del mercado inmobiliario, y que debe comunicarse con honestidad metodológica.

  • El índice KMO global obtenido para el PCA se ubica en el rango mediocre-aceptable de la escala de Kaiser: hay estructura factorizable real, pero no tan fuerte como en escenarios psicométricos clásicos. Esto no invalida el PCA, pero sugiere que un porcentaje relevante de la varianza de cada variable es específica (no compartida), coherente con que solo 2 componentes expliquen el 80,9 % y no una proporción aún mayor.

  • ANOVA asume normalidad y homogeneidad de varianzas, supuestos que probablemente se violan dada la asimetría de preciom y areaconst (por los outliers conservados). Se contrastó con Kruskal-Wallis (no paramétrico) como validación adicional, que confirmó las mismas conclusiones.

9 Referencias

  • Kaufman, L., & Rousseeuw, P. J. (1990). Finding Groups in Data: An Introduction to Cluster Analysis. Wiley.
  • 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.
  • Greenacre, M. (2017). Correspondence Analysis in Practice (3rd ed.). CRC Press.
  • Kaiser, H. F. (1974). An index of factorial simplicity. Psychometrika, 39(1), 31–36.
  • Cohen, J. (1988). Statistical Power Analysis for the Behavioral Sciences (2nd ed.). Lawrence Erlbaum Associates.
  • centromagis (2023). paqueteMODELOS: datos y funciones de apoyo para Modelos Estadísticos. Pontificia Universidad Javeriana Cali. https://github.com/centromagis/paqueteMODELOS