1 Contexto de negocio y objetivos

1.1 Problema

Una empresa inmobiliaria líder en una gran ciudad está buscando comprender en profundidad el mercado de viviendas urbanas para tomar decisiones estratégicas más informadas. La empresa posee una base de datos extensa que contiene información detallada sobre diversas propiedades residenciales disponibles en el mercado. Se requiere realizar un análisis holístico de estos datos para identificar patrones, relaciones y segmentaciones relevantes que permitan mejorar la toma de decisiones en cuanto a la compra, venta y valoración de propiedades.

1.2 Objetivo general

Realizar un análisis integral y multidimensional de la base de datos para obtener una comprensión del mercado inmobiliario urbano aplicando técnicas de análisis multivariado.

1.3 Objetivos específicos

  • Realizar un análisis de componentes principales, reduciendo la dimensionalidad del data set y visualizando la estructura de las variables en los componentes principales.

  • Realizar un análisis de conglomerados, agrupando las propiedades residenciales en segmentos homogéneos con características similares para entender las dinámicas de la oferta.

  • Realizar un análisis de correspondencia, examinando las relaciones entre las variables categóricas (tipo de vivienda, zona y barrio) para identificar patrones de comportamiento de la oferta en el mercado.

  • Visualizar los resultados mediante gráficos, mapas y otros recursos visuales para comunicar los hallazgos de manera clara y efectiva a la dirección de la empresa.

2 Comprensión de los datos

2.1 Dimensiones y tipos

## El data set tiene 8322 observaciones
## El data set tiene 13 columnas
## spc_tbl_ [8,322 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
##  $ id          : num [1:8322] 1147 1169 1350 5992 1212 ...
##  $ zona        : chr [1:8322] "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
##  $ piso        : chr [1:8322] NA NA NA "02" ...
##  $ estrato     : num [1:8322] 3 3 3 4 5 5 4 5 5 5 ...
##  $ preciom     : num [1:8322] 250 320 350 400 260 240 220 310 320 780 ...
##  $ areaconst   : num [1:8322] 70 120 220 280 90 87 52 137 150 380 ...
##  $ parqueaderos: num [1:8322] 1 1 2 3 1 1 2 2 2 2 ...
##  $ banios      : num [1:8322] 3 2 2 5 2 3 2 3 4 3 ...
##  $ habitaciones: num [1:8322] 6 3 4 3 3 3 3 4 6 3 ...
##  $ tipo        : chr [1:8322] "Casa" "Casa" "Casa" "Casa" ...
##  $ barrio      : chr [1:8322] "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
##  $ longitud    : num [1:8322] -76.5 -76.5 -76.5 -76.5 -76.5 ...
##  $ latitud     : num [1:8322] 3.43 3.43 3.44 3.44 3.46 ...
##  - attr(*, "spec")=
##   .. cols(
##   ..   id = col_double(),
##   ..   zona = col_character(),
##   ..   piso = col_character(),
##   ..   estrato = col_double(),
##   ..   preciom = col_double(),
##   ..   areaconst = col_double(),
##   ..   parqueaderos = col_double(),
##   ..   banios = col_double(),
##   ..   habitaciones = col_double(),
##   ..   tipo = col_character(),
##   ..   barrio = col_character(),
##   ..   longitud = col_double(),
##   ..   latitud = col_double()
##   .. )
##  - attr(*, "problems")=<externalptr>

En cuanto a las variables y sus tipos, en la Tabla 1:

Variable Tipo de dato Descripción
id Numeric Identificador único de la propiedad
zona Character Zona geográfica donde se encuentra la propiedad
piso Character Nivel de la torre (apartamentos) o número de plantas (casas). Ver sección de heterogeneidad semántica
estrato Numeric Estrato socioeconómico de la vivienda
preciom Numeric Precio de la propiedad en millones de pesos
areaconst Numeric Área construida de la propiedad
parqueaderos Numeric Número de parqueaderos disponibles
banios Numeric Cantidad de baños de la propiedad
habitaciones Numeric Número de habitaciones
tipo Character Tipo de vivienda (casa, apartamento)
barrio Character Barrio donde se ubica la propiedad
longitud Numeric Coordenada geográfica de longitud
latitud Numeric Coordenada geográfica de latitud

2.2 Duplicados y registros inutilizables

## El total de duplicados exactos es 1
## El total de duplicados ignorando el id es 58
## Registros con todas las columnas ausentes: 2
## Registros con 12 o más columnas ausentes de 13: 3
## Registros sin identificador: 3

El conjunto presenta muy pocos registros repetidos. Al examinar su naturaleza se comprueba que corresponden a filas prácticamente vacías, que no aportan información utilizable y distorsionan los patrones de ausencia analizados a continuación.Estos registros fueron considerados inconsistentes y eliminados antes de los análisis.

# Se eliminan las filas sin información utilizable (12 o más de 13 columnas ausentes).
# Un filtro por `if_all(everything(), is.na)` no las capturaría, porque conservan
# el precio y por tanto no están completamente vacías.
vivienda <- vivienda[rowSums(is.na(vivienda)) < ncol(vivienda) - 1, ]

cat("Observaciones tras el tratamiento:", nrow(vivienda), "\n")
## Observaciones tras el tratamiento: 8319
cat("Registros vacíos restantes:",
    sum(rowSums(is.na(vivienda)) >= ncol(vivienda) - 1), "\n")
## Registros vacíos restantes: 0

Tras la depuración el conjunto queda reducido a 8319 observaciones, lo que representa una pérdida despreciable frente al tamaño y no compromete la representatividad de los análisis posteriores.

2.3 Diagnóstico de datos faltantes

2.3.1 Alcance de lo que puede demostrarse

Antes de comenzar conviene precisar qué se puede concluir y qué no. Sea \(R_j\) el indicador de ausencia de la variable \(X_j\):

Mecanismo Definición ¿Contrastable?
MCAR \(P(R \mid X) = P(R)\) , mediante el test de Little y la dependencia con variables observadas
MAR \(P(R \mid X) = P(R \mid X_{obs})\) No directamente; solo se refuta MCAR y se argumenta MAR
MNAR \(P(R)\) depende de \(X_{mis}\) No; requeriría conocer los valores ausentes

MAR y MNAR son observacionalmente equivalentes, por lo que el objetivo realista no es determinar el mecanismo sino: refutar MCAR con evidencia formal, justificar MAR como supuesto de trabajo, y acotar mediante análisis de sensibilidad qué tan frágiles serían las conclusiones si MAR no se cumpliera.

2.3.2 Exploración inicial

Se realiza una exploración inicial para determinar qué variables presentan datos ausentes y cómo se distribuyen.

## El total de valores ausentes en el data set es de 4237
## El porcentaje de celdas ausentes en el data set es de 3.92 %

Revisando con el bloque de código anterior se nota que el conjunto presenta un total de 4249 datos faltantes, lo que representa el 3.93% del total de celdas del data set (no de registros: al distribuirse en dos columnas, la proporción de filas afectadas es mucho mayor). Además, los datos ausentes se concentran en las variables piso y parqueaderos, que entre las dos reúnen el 99.8% de los valores faltantes. Ahora es necesario entender si esta falta de valores se debe a alguna condición específica de el data set.

2.3.3 Patrones de ausencia

La tabla confirma que la ausencia se concentra en dos variables: piso, con un 31% de registros incompletos, y parqueaderos, con un 19.3%. El resto de las variables presenta ausencia prácticamente nula, lo que acota el problema a un ámbito reducido y manejable.

gg_miss_var(vivienda)
Ausentes por variable.

Ausentes por variable.

La representación ordena las variables por volumen de ausencia y evidencia la distancia entre las dos variables afectadas y el resto

vis_miss(vivienda, warn_large_data = FALSE)
Matriz de ausencias por variable y observación.

Matriz de ausencias por variable y observación.

La matriz permite apreciar que la ausencia no se concentra en tramos contiguos de la base sino que se distribuye a lo largo de todo el conjunto, lo que descarta que el problema provenga de un bloque concreto de registros por ejemplo, una carga de datos fallida en una fecha determinada.

gg_miss_upset(vivienda)
Patrones conjuntos de ausencia.

Patrones conjuntos de ausencia.

md.pattern(vivienda, rotate.names = TRUE)
Patrones conjuntos de ausencia.

Patrones conjuntos de ausencia.

##      id zona estrato preciom areaconst banios habitaciones tipo barrio longitud
## 4808  1    1       1       1         1      1            1    1      1        1
## 1909  1    1       1       1         1      1            1    1      1        1
## 876   1    1       1       1         1      1            1    1      1        1
## 726   1    1       1       1         1      1            1    1      1        1
##       0    0       0       0         0      0            0    0      0        0
##      latitud parqueaderos piso     
## 4808       1            1    1    0
## 1909       1            1    0    1
## 876        1            0    1    1
## 726        1            0    0    2
##            0         1602 2635 4237

Los valores ausentes se concentran en las variables piso y parqueaderos, y el patrón no es monótono: existen registros a los que les falta solo una de las dos, y registros a los que les faltan ambas. Esa coocurrencia es precisamente lo que examinamos más adelante para refutar MCAR.

2.3.4 Heterogeneidad semántica de la variable piso

vivienda |>
  mutate(piso_falta = is.na(piso)) |>
  count(tipo, piso_falta) |>
  pivot_wider(names_from = piso_falta, values_from = n, values_fill = 0,
              names_prefix = "NA_")
vivienda |>
  group_by(tipo) |>
  summarise(n = n(), na_piso = sum(is.na(piso)),
            pct_na = round(100 * mean(is.na(piso)), 1), .groups = "drop")
vivienda |>
  count(tipo, piso) |>
  pivot_wider(names_from = tipo, values_from = n, values_fill = 0)

Los NA se encuentran presentes tanto en apartamentos como en casas (27.1% y 39.0% respectivamente), por lo que no se trata de una ausencia estructural: si el atributo simplemente no aplicara a las casas, esperaríamos ausencia total en ese grupo y valores completos en el otro, y no es lo que encontramos. Ambos subconjuntos registran valores y ambos presentan ausencia sustancial, de modo que la ausencia es genuina.

No obstante, al revisar los valores observados se nota que las casas se concentran en 1, 2 y 3 pisos (96.3% de las casas), mientras que los apartamentos se distribuyen hasta el piso 12. Esto nos indica que el significado de la variable piso para Casa se refiere a la cantidad de plantas que tiene la vivienda, mientras que para Apartamento se refiere a en qué piso de la torre se encuentra. Se trata de dos constructos distintos registrados en la misma columna, por lo que cualquier estadístico calculado sobre piso sin separar por tipo carece de interpretación.

En base a esto se realiza la separación para hacer un tratamiento por separado:

# Separación en dos variables con significado homogéneo
vivienda <- vivienda |>
  mutate(
    piso_num   = as.integer(piso),
    n_plantas  = if_else(tipo == "Casa",        piso_num, NA_integer_),
    piso_torre = if_else(tipo == "Apartamento", piso_num, NA_integer_)
  )

A partir de este punto la variable piso en su forma original no vuelve a utilizarse: toda técnica emplea n_plantas o piso_torre según el subconjunto, lo que impide promediar magnitudes no comparables.

# Valores implausibles: casas con 5 o más plantas
vivienda |> filter(tipo == "Casa", n_plantas >= 5) |> count(n_plantas)

2.3.5 Refutación de MCAR mediante la dependencia entre ausencias

Si el mecanismo fuera MCAR, los indicadores de ausencia de piso y parqueaderos serían independientes entre sí.

tab <- table(piso_NA = is.na(vivienda$piso),
             parq_NA = is.na(vivienda$parqueaderos))
tab
##        parq_NA
## piso_NA FALSE TRUE
##   FALSE  4808  876
##   TRUE   1909  726
chisq.test(tab)
## 
##  Pearson's Chi-squared test with Yates' continuity correction
## 
## data:  tab
## X-squared = 169.88, df = 1, p-value < 2.2e-16
round(prop.table(tab, margin = 1) * 100, 1)   # % de NA en parqueaderos según estado de piso
##        parq_NA
## piso_NA FALSE TRUE
##   FALSE  84.6 15.4
##   TRUE   72.4 27.6

Se obtuvo \(\chi^2_{(1)} = 171.4\) con \(p < 10^{-16}\): la ausencia de parqueaderos pasa de 15.4% cuando piso está observado a 27.6% cuando está ausente, por consiguiente se duplica el riesgo de ausencia. El patrón sugiere registros incompletos originados en una misma fuente o proceso de captura, dicho de otro modo una causa común observable. Destacamos que este contraste no requiere ningún supuesto distribucional, a diferencia del test de Little que aplicamos a continuación, por lo que constituye la evidencia más robusta de esta sección.

2.3.6 Test de Little

# Se construye antes de recodificar `parqueaderos`, para poder contrastar
# más adelante el tratamiento alternativo por imputación
viv_num <- vivienda |>
  transmute(log_precio = log(preciom), log_area = log(areaconst),
            estrato, banios, habitaciones, parqueaderos, piso_num)

mcar_test(viv_num)

Se aplicó la prueba de Little para evaluar si los datos correspondían a un patrón completamente aleatorio. La prueba obtuvo un estadístico de \(\chi^2 = 1531.1\) con 17 grados de libertad y un valor p inferior a 0.001. Debido a que el valor p es menor que el nivel de significancia del 5%, se rechaza la hipótesis nula de que los datos faltantes sean completamente aleatorios (MCAR). Adicionalmente, se identificaron cuatro patrones de ausencia en las variables analizadas.

Es necesario declarar las limitaciones de esta prueba: asume normalidad multivariada (motivo por el cual aplicamos logaritmos a precio y área, aunque el supuesto sigue siendo cuestionable), solo admite variables numéricas de modo que ignora zona, tipo y barrio, que son candidatas naturales a explicar la ausencia, y con \(n\) superior a 8000 la potencia es tal que desviaciones triviales producen rechazo. Por estas razones la reportamos como evidencia complementaria y no como base de la decisión, que descansa en el contraste de la sección anterior.

2.3.7 Contrastes bivariados, estratificados por tipo

diagnostico_na <- function(datos, var) {
  datos |>
    mutate(R = factor(is.na(.data[[var]]), labels = c("observado", "ausente"))) |>
    select(R, preciom, areaconst, estrato, banios, habitaciones) |>
    pivot_longer(-R, names_to = "variable", values_to = "valor") |>
    group_by(variable) |>
    summarise(
      mediana_obs = median(valor[R == "observado"], na.rm = TRUE),
      mediana_aus = median(valor[R == "ausente"],   na.rm = TRUE),
      p_wilcox    = wilcox.test(valor ~ R)$p.value,
      .groups = "drop") |>
    mutate(p_BH = p.adjust(p_wilcox, method = "BH"))
}

diagnostico_na(filter(vivienda, tipo == "Casa"),        "piso")
diagnostico_na(filter(vivienda, tipo == "Apartamento"), "piso")
diagnostico_na(vivienda, "parqueaderos")
chisq.test(table(is.na(vivienda$piso), vivienda$zona))
## 
##  Pearson's Chi-squared test
## 
## data:  table(is.na(vivienda$piso), vivienda$zona)
## X-squared = 148.66, df = 4, p-value < 2.2e-16
chisq.test(table(is.na(vivienda$piso), vivienda$tipo))
## 
##  Pearson's Chi-squared test with Yates' continuity correction
## 
## data:  table(is.na(vivienda$piso), vivienda$tipo)
## X-squared = 128.1, df = 1, p-value < 2.2e-16

Se compararon las distribuciones de variables observadas entre registros con información disponible y ausente mediante pruebas de Wilcoxon elegidas sobre la prueba t por la fuerte asimetría de precio y área, aplicando la corrección de Benjamini-Hochberg por comparaciones múltiples. En el caso de las viviendas tipo casa, la ausencia de piso presentó asociaciones significativas con el área construida, número de baños, estrato y precio, mientras que no se encontró evidencia significativa para el número de habitaciones. Para los apartamentos, ninguna de estas variables presentó diferencias significativas después de la corrección por comparaciones múltiples. Por otra parte, la ausencia de parqueaderos mostró asociaciones altamente significativas con todas las variables analizadas. Adicionalmente, las pruebas Chi-cuadrado evidenciaron una asociación significativa entre la ausencia de piso y tanto la zona (\(\chi^2 = 148.66\), \(p < 0.001\)) como el tipo de vivienda (\(\chi^2 = 128.10\), \(p < 0.001\)).

En conjunto, estos resultados permiten descartar el supuesto MCAR. Conviene subrayar que no permiten confirmar MAR: la dependencia con las variables observadas es la firma esperada bajo MAR, pero también es compatible con un mecanismo MNAR, razón por la cual recurrimos al análisis de sensibilidad.

2.3.8 La variable parqueaderos: ¿NA significa cero?

Al revisar las características de la variable parqueaderos se observa que no cuenta con el valor 0 en ningún registro, y en su lugar cuenta con valores NA. Sin embargo es necesario verificar antes de imputar y descartar que la ausencia codifique un valor real, ya que si NA equivale a “sin parqueadero”, imputar por mediana introduciría un sesgo sistemático al alza.

# Verificación de la premisa: ¿existe realmente el valor cero?
vivienda |> count(parqueaderos) |> arrange(parqueaderos)

El recuento confirma la premisa: la variable parqueaderos no registra el valor cero en ninguna observación, pese a que la ausencia de parqueadero es una característica frecuente en el segmento residencial. Esta anomalía es el primer indicio de que el valor NA está codificando el cero.

vivienda |>
  mutate(grupo = case_when(is.na(parqueaderos) ~ "NA",
                           parqueaderos == 0   ~ "cero",
                           TRUE                ~ "uno o mas")) |>
  group_by(grupo) |>
  summarise(n = n(), precio_med = median(preciom, na.rm = TRUE),
            area_med = median(areaconst, na.rm = TRUE),
            estrato_med = median(estrato, na.rm = TRUE), .groups = "drop")

Como se observa en los resultados, las viviendas con valor NA tienden a tener un precio, un área y un estrato menores que las que tienen un parqueadero o más, que es exactamente el perfil que esperaríamos de una vivienda sin parqueadero. Sin embargo, antes de imputar o realizar alguna modificación a la base de datos se revisa si la falta de parqueaderos se puede asociar con el estrato de la vivienda:

vivienda |>
  mutate(parq_NA = is.na(parqueaderos)) |>
  group_by(estrato) |>
  summarise(n = n(), porcentaje_NA = round(mean(parq_NA) * 100, 1), .groups = "drop")

La proporción de ausencia decrece de forma sostenida al aumentar el estrato, lo que refuerza la interpretación: no se trata de un fallo aleatorio de captura sino de la ausencia real del atributo en los segmentos donde el parqueadero es menos frecuente. Con estas tres evidencias inexistencia del valor cero, perfil de precio y área consistente con la carencia, y gradiente por estrato decidimos convertir los NA de la variable parqueaderos en 0.

vivienda <- vivienda |>
  mutate(parqueaderos = replace_na(parqueaderos, 0))

Tras la recodificación la variable parqueaderos queda completa, con N registros en el valor cero. La decisión se somete a verificación en el análisis de sensibilidad del apartado siguiente.

2.3.9 Análisis de sensibilidad frente a escenarios MNAR

La decisión anterior es una decisión de codificación, no una imputación, y conviene verificar que las conclusiones del análisis no dependan de ella. Para ello contrastamos el tratamiento alternativo: imputar parqueaderos bajo el supuesto MAR y desplazar sistemáticamente los valores imputados en un rango \(\delta\), verificando si los coeficientes se mantienen estables. La rama \(\delta < 0\) aproxima precisamente el escenario en que los registros ausentes tienen menos parqueaderos, por consiguiente la hipótesis que motivó la recodificación a cero.

imp <- mice(viv_num, m = 5, method = "pmm", printFlag = FALSE)

deltas <- c(-1, -0.5, 0, 0.5, 1)   # desplazamiento en unidades de la variable imputada
resultados <- lapply(deltas, function(d) {
  imp_d <- imp
  # `parqueaderos` es un conteo no negativo: se restringe el dominio
  imp_d$imp$parqueaderos <- pmax(imp_d$imp$parqueaderos + d, 0)
  fit <- with(imp_d, lm(log_precio ~ log_area + factor(estrato) + parqueaderos))
  s <- summary(pool(fit), conf.int = TRUE)
  data.frame(delta = d, s[, c("term", "estimate", "2.5 %", "97.5 %", "p.value")])
})
do.call(rbind, resultados) |> filter(term == "parqueaderos")

El análisis de sensibilidad por desplazamiento evaluó escenarios MNAR con \(\delta \in [-1, 1]\) parqueaderos sobre los valores imputados. Los coeficientes de log_area y estrato variaron menos de 10% y ningún efecto cambió de signo ni perdió significancia (\(p < 10^{-16}\) en todos los escenarios). El coeficiente de parqueaderos varió 37.3% en el rango completo, pero únicamente 5.2% al restringirse a la dirección sustantivamente plausible (\(\delta \leq 0\)), que corresponde a que los inmuebles sin dato declarado tengan menos parqueaderos. En términos interpretables, el efecto se mantiene entre 8.7% y 9.2% de incremento de precio por parqueadero adicional dentro de los escenarios creíbles.

Concluimos que las estimaciones son robustas frente a violaciones del supuesto MAR en la dirección relevante, y que la recodificación a cero no compromete los resultados posteriores.

2.3.10 Síntesis y decisiones

Variable % NA Naturaleza Tratamiento
piso (casas) 39.0 Faltante genuino; constructo = n.º de plantas Separar en n_plantas; imputación pendiente bajo MAR
piso (aptos) 27.1 Faltante genuino; constructo = posición en torre Separar en piso_torre; imputación pendiente bajo MAR
parqueaderos 19.3 La ausencia codifica el valor cero Recodificación a 0, validada por sensibilidad
Casas con ≥5 plantas Error de registro (11 casos) Sección de inconsistencias
Registros vacíos Sin información utilizable Eliminación documentada

Se refuta MCAR con dos evidencias independientes: la dependencia entre indicadores de ausencia (\(\chi^2_{(1)} = 171.4\)), que no requiere supuestos distribucionales, y los contrastes bivariados con las variables observadas. Se adopta MAR como supuesto de trabajo declarado, no como hecho demostrado, justificado por la capacidad predictiva de las variables observadas sobre la ausencia. La robustez ante escenarios MNAR se acota por sensibilidad.

El supuesto más frágil es que el modelo de imputación contenga todas las variables asociadas simultáneamente a la ausencia y al valor faltante; como ninguna variable no medida puede descartarse, el supuesto MAR es indemostrable por construcción. Dado que las ausencias afectan al 31.7% y 19.3% de dos variables relevantes, este es el punto de mayor vulnerabilidad del estudio.

2.4 Determinación del tipo de la variable piso

Establecido que piso_torre mide la posición del apartamento dentro de la torre, resta decidir cómo debe entrar en los análisis multivariados: como numérica, como factor o como ordinal agrupada. Esta sección documenta la evidencia sobre la que se tomó la decisión. El análisis se restringe a apartamentos porque, como establecimos, en casas la variable mide otro constructo.

viv <- vivienda |>
  filter(tipo == "Apartamento", !is.na(piso_torre)) |>
  mutate(piso_fac = factor(piso_torre))

nrow(viv)  # dominio efectivo del análisis
## [1] 3719

Se aclara que este análisis se realizó sobre el conjunto previo a la depuración y que los registros implicados son menos del 0.2%

2.4.1 Soporte por nivel

viv |> count(piso_torre) |> mutate(porcentaje = n / sum(n) * 100) |> arrange(piso_torre)

Se realiza un conteo de cuántas viviendas tenemos por cada uno de los niveles. Todos los niveles superan las 80 observaciones, por lo que la inestabilidad de las estimaciones no constituye un argumento para descartar el tratamiento como factor: los 12 niveles son perfectamente estimables.

Lo que sí llama la atención es la caída del conteo entre el piso 5 (564 registros) y el piso 6 (243): una reducción del 57% que no se repite en ningún otro tramo, donde las razones entre niveles consecutivos se mantienen cercanas a 1. Esto sugiere que el parque de edificios está dominado por torres de exactamente cinco pisos el 70.6% de la oferta se concentra en los niveles 1 a 5 lo cual es coherente con la hipótesis del ascensor que retomamos al analizar la forma del efecto.

2.4.2 ¿El efecto es monótono?

cor.test(viv$piso_torre, log(viv$preciom), method = "spearman")
## 
##  Spearman's rank correlation rho
## 
## data:  viv$piso_torre and log(viv$preciom)
## S = 7057548662, p-value < 2.2e-16
## alternative hypothesis: true rho is not equal to 0
## sample estimates:
##       rho 
## 0.1767597

Se prueba con la prueba de correlación de Spearman entre el piso del apartamento y el logaritmo del precio de las viviendas; se utiliza el logaritmo para reducir la influencia de los valores extremos. Como se obtuvo un valor p < 0.05 se evidencia que tenemos una asociación entre el piso y el precio de la vivienda, y el rho de 0.17 nos indica una tendencia positiva, aunque de magnitud débil. Es importante notar que esta prueba confirma que existe orden, pero no que los intervalos entre pisos consecutivos representen incrementos equivalentes: eso es justamente lo que contrastamos a continuación.

2.4.3 Forma del efecto (linealidad)

viv |>
  group_by(piso_torre) |>
  summarise(n = n(), m = mean(log(preciom)), se = sd(log(preciom))/sqrt(n),
            .groups = "drop") |>
  filter(n >= 30) |>
  ggplot(aes(piso_torre, m)) +
  geom_pointrange(aes(ymin = m - 1.96*se, ymax = m + 1.96*se)) +
  geom_smooth(method = "lm", se = FALSE, linetype = 2) +
  labs(x = "Piso", y = "log(precio en millones)") +
  theme_minimal()
Precio medio (log) por piso, con IC 95%. Línea punteada: ajuste lineal.

Precio medio (log) por piso, con IC 95%. Línea punteada: ajuste lineal.

Se observa gráficamente que no se cuenta con una tendencia lineal entre el piso y el precio. Lo que encontramos son dos mesetas separadas por un escalón: los pisos 1 a 4 forman un bloque sin diferencias apreciables entre sí (sus intervalos de confianza se solapan por completo), y a partir del piso 6 el precio salta a un nivel superior que se mantiene, con oscilaciones, hasta el piso 12. La diferencia entre ambos bloques es de 0.366 en escala logarítmica, equivalente a un 44% de precio.

Adicionalmente se cuenta con una caída de precio cuando el valor de piso es igual a 5, que es el único punto que rompe la monotonía y cuyo intervalo no se solapa ni con el piso 4 ni con el 6. Teniendo en cuenta el contexto de Colombia esto podría referirse a las viviendas que por normatividad después del piso 5 requieren ascensor: los pisos 5 corresponderían a torres de apartamentos cuyo máximo piso es 5 y no cuentan con ascensor, por lo que el último nivel tiende a tener un valor menor. Esta lectura es consistente con la caída del 57% en el conteo que documentamos en la sección anterior: ambas señales, precio y frecuencia, sitúan el quiebre en el mismo punto.

2.4.4 Contraste formal: modelos anidados

El modelo con piso numérico está anidado en el modelo con piso como factor: el primero impone la restricción de efectos equiespaciados. La restricción es contrastable.

m_num <- lm(log(preciom) ~ piso_torre + log(areaconst) + factor(estrato) + zona, data = viv)
m_fac <- lm(log(preciom) ~ piso_fac   + log(areaconst) + factor(estrato) + zona, data = viv)

anova(m_num, m_fac)
AIC(m_num, m_fac); BIC(m_num, m_fac)

El contraste F entre modelos anidados rechaza la restricción de equiespaciamiento (\(F_{(10,\,3699)} = 8.87\), \(p = 1.5 \times 10^{-14}\)), por lo que el efecto del piso sobre el precio no sigue una relación lineal constante. La comparación mediante criterios de información también muestra un mejor desempeño del modelo con piso tratado como factor (AIC = −582.97; BIC = −452.33) frente al modelo numérico (AIC = −514.87; BIC = −446.44).

Sin embargo la diferencia en BIC es de apenas 5.89 puntos frente a 68.10 en AIC. Dado que BIC penaliza cada parámetro con \(\log(n) \approx 8.2\) en lugar de 2, los 10 grados de libertad adicionales cuestan 82 puntos bajo BIC y solo 20 bajo AIC. Que el factor apenas sobreviva al criterio más exigente nos indica que existe no linealidad real, pero que no justifica gastar 10 parámetros: la mejora práctica es marginal, ya que el error residual solo baja de 0.2254 a 0.2231. Por esta razón evaluamos una especificación intermedia.

2.4.5 Especificación intermedia: ordinal agrupada

Definimos los cortes a partir del patrón identificado en el gráfico: los pisos 1 a 4 forman una meseta homogénea, el piso 5 se separa hacia abajo por el efecto del ascensor, y a partir del piso 6 el precio se sitúa en un nivel superior.

viv <- viv |>
  mutate(piso_cat = cut(piso_torre, breaks = c(0, 4, 5, Inf),
                        labels = c("1-4", "5", "6+"),
                        ordered_result = TRUE))

m_cat <- lm(log(preciom) ~ piso_cat + log(areaconst) + factor(estrato) + zona, data = viv)

anova(m_num, m_cat, m_fac)
BIC(m_num, m_cat, m_fac)

‘El contraste conjunto indica que m_cat no difiere significativamente de m_fac. El criterio BIC favorece a m_cat, por lo que se concluye que piso_torre debe tratarse como variable ordinal agrupada.’

Debemos declarar una limitación de este procedimiento: los cortes se definieron observando el patrón en los mismos datos con los que después se evalúan, lo que sesga levemente el BIC a su favor. Se mitigó anclando los cortes a un criterio sustantivo previo la normativa de ascensor y no a una búsqueda exhaustiva del mejor corte posible.

2.4.6 Decisión y uso por técnica

Técnica Requisito Versión utilizada
ACP numérica estandarizada piso_torre
k-means numérica estandarizada piso_torre
Clúster con distancia de Gower admite ordinal piso_cat (ordered)
Análisis de correspondencias múltiples categórica piso_cat

La variable piso fue evaluada en tres representaciones alternativas: numérica, categórica agrupada y categórica individual. La comparación mostró un mejor comportamiento del modelo al utilizar la variable categórica agrupada. La decisión no descansa únicamente en el contraste de modelos anidados, sino en que la distribución de frecuencias y el perfil de precios identifican de forma independiente un mismo punto de ruptura entre el piso 5 y el 6, consistente con la existencia de dos segmentos constructivos edificios sin ascensor, 70.6% de la oferta, y edificios con ascensor, 29.4% en los que un incremento unitario de piso no representa la misma magnitud.

Decisión. Se conservan ambas versiones en el data set y se emplea la que cada técnica requiere, declarando en cada caso el supuesto asumido. Para las técnicas que exigen entrada numérica se emplea piso_torre estandarizada, dejando constancia del supuesto de equiespaciamiento que ello impone.

2.5 Errores e inconsistencias

Definimos las condiciones que todo registro válido debe cumplir por la naturaleza del fenómeno, no por consideraciones estadísticas.

Regla Justificación
preciom > 0 Un inmueble en oferta tiene precio positivo
areaconst > 0 Un inmueble construido tiene área positiva
estrato \(\in \{1,\dots,6\}\) La estratificación socioeconómica colombiana solo admite seis niveles
banios \(\geq 1\) Una vivienda habitable requiere al menos un baño
habitaciones \(\geq 1\) Una vivienda habitable requiere al menos un espacio de habitación
parqueaderos \(\geq 0\) Es un conteo
n_plantas \(\leq 4\) Una casa de más de cuatro plantas no corresponde a vivienda unifamiliar
Coordenadas dentro del área de estudio El conjunto describe una sola ciudad
id único Es el identificador del registro
inconsistencias <- vivienda |>
  mutate(
    e_precio      = is.na(preciom)   | preciom   <= 0,
    e_area        = is.na(areaconst) | areaconst <= 0,
    e_estrato     = is.na(estrato)   | !(estrato %in% 1:6),
    e_banios      = !is.na(banios)       & banios       < 1,
    e_habitac     = !is.na(habitaciones) & habitaciones < 1,
    e_parq        = !is.na(parqueaderos) & parqueaderos < 0,
    e_plantas     = !is.na(n_plantas)    & n_plantas    > 4
  )

inconsistencias |>
  summarise(across(starts_with("e_"), sum)) |>
  pivot_longer(everything(), names_to = "regla", values_to = "registros") |>
  mutate(porcentaje = round(100 * registros / nrow(vivienda), 3)) |>
  arrange(desc(registros))

Es decir tenemos entre 66 a 124 posibles observaciones que no cumplen con las reglas que establecidos mas adeltante se calculara correctamente la union para obtener la cifra exacta

cat("Identificadores duplicados:", sum(duplicated(vivienda$id)), "\n")
## Identificadores duplicados: 0
cat("Rango de estrato observado:", paste(range(vivienda$estrato, na.rm = TRUE), collapse = " - "), "\n")
## Rango de estrato observado: 3 - 6
cat("Área construida mínima:", min(vivienda$areaconst, na.rm = TRUE), "m2\n")
## Área construida mínima: 30 m2
cat("Precio mínimo:", min(vivienda$preciom, na.rm = TRUE), "millones\n")
## Precio mínimo: 58 millones

No se detectan identificadores duplicados, lo que confirma que id cumple su función de clave única. El estrato observado se mantiene dentro del rango admisible de 1 a 6. Los valores mínimos de área y precio se examinan a continuación por resultar implausiblemente bajos.

vivienda |>
  filter(areaconst < 25 | preciom < 20) |>
  select(id, tipo, zona, estrato, preciom, areaconst, banios, habitaciones) |>
  arrange(areaconst)

2.5.1 Coordenadas fuera del área de estudio

El conjunto describe una sola ciudad, de modo que las coordenadas deben concentrarse en un área geográfica reducida. Verificamos primero la dispersión global

vivienda |>
  summarise(across(c(longitud, latitud),
                   list(min = ~min(.x, na.rm = TRUE),
                        q01 = ~quantile(.x, 0.01, na.rm = TRUE),
                        q99 = ~quantile(.x, 0.99, na.rm = TRUE),
                        max = ~max(.x, na.rm = TRUE))))
ggplot(vivienda, aes(longitud, latitud)) +
  geom_point(alpha = 0.15, size = 0.7) +
  coord_fixed() +
  labs(x = "Longitud", y = "Latitud") +
  theme_minimal()
Dispersión geográfica de la oferta.

Dispersión geográfica de la oferta.

La nube de puntos reproduce la silueta urbana de la ciudad y no se observan registros aislados a distancia apreciable del conglomerado principal, de modo que no hay evidencia de errores gruesos de geocodificación.

2.5.2 Consolidación y tratamiento

vivienda <- vivienda |>
  mutate(
    inconsistente =
      (is.na(preciom)   | preciom   <= 0) |
      (is.na(areaconst) | areaconst <= 0) |
      (is.na(estrato)   | !(estrato %in% 1:6)) |
      (!is.na(banios)       & banios       < 1) |
      (!is.na(habitaciones) & habitaciones < 1) |
      (!is.na(n_plantas)    & n_plantas    > 4)
  )

vivienda |> count(inconsistente) |> mutate(pct = round(100 * n / sum(n), 3))

El criterio aplicado es eliminar únicamente los registros que violan reglas de dominio, es decir aquellos cuyo valor no puede corresponder a ninguna vivienda real. No se eliminan valores extremos en esta etapa: esa decisión se toma en la sección siguiente y bajo criterios distintos. Distinguir ambas operaciones es importante porque un error de dominio es incorregible, mientras que un valor extremo puede ser información legítima sobre el segmento alto del mercado, que es precisamente uno de los objetos de estudio.

n_antes <- nrow(vivienda)

vivienda <- vivienda |> filter(!inconsistente)

cat("Registros eliminados por inconsistencia:", n_antes - nrow(vivienda), "\n")
## Registros eliminados por inconsistencia: 87
cat("Observaciones restantes:", nrow(vivienda), "\n")
## Observaciones restantes: 8232

Se eliminan 87 registros por infracción de reglas de dominio, equivalentes al 1,04% del conjunto. La magnitud reducida de esta cifra indica una calidad elevada en cuanto a validez de dominio: los problemas relevantes del conjunto se concentran en la ausencia de datos y en la heterogeneidad semántica, no en la presencia de valores imposibles.

2.6 Atípicos

La detección univariada mediante el criterio del rango intercuartílico es insuficiente en este conjunto por dos razones. La primera es que preciom y areaconst presentan una fuerte asimetría positiva, de modo que el criterio \(Q_3 + 1.5\,\text{IQR}\) marca una fracción muy alta de la cola derecha sin que esos registros sean anómalos: en un mercado inmobiliario la existencia de propiedades de precio muy superior a la mediana es una característica estructural, no un defecto de los datos. La segunda es que un registro puede ser perfectamente normal en cada variable por separado y resultar imposible en conjunto: un inmueble de 400 m² no es raro y un precio de 90 millones tampoco, pero 400 m² a 90 millones sí lo es.

Por ello combinamos cuatro enfoques complementarios:

  1. Univariado robusto, sobre variables transformadas y usando la mediana y la desviación absoluta mediana en lugar de media y desviación estándar, que son ellas mismas sensibles a los atípicos.
  2. Precio por metro cuadrado, que resume en un solo indicador la coherencia entre las dos variables centrales.
  3. Contextual, evaluando cada inmueble frente a los de su mismo estrato y zona en lugar de frente al mercado completo.
  4. Multivariado, mediante distancia de Mahalanobis con estimación robusta de centro y covarianza.

2.6.1 Detección univariada robusta

z_robusto <- function(x) {
  0.6745 * (x - median(x, na.rm = TRUE)) / mad(x, na.rm = TRUE)
}

vivienda <- vivienda |>
  mutate(
    log_precio = log(preciom),
    log_area   = log(areaconst),
    z_precio   = z_robusto(log_precio),
    z_area     = z_robusto(log_area)
  )

vivienda |>
  summarise(
    atip_precio = sum(abs(z_precio) > 3.5, na.rm = TRUE),
    atip_area   = sum(abs(z_area)   > 3.5, na.rm = TRUE),
    pct_precio  = round(100 * mean(abs(z_precio) > 3.5, na.rm = TRUE), 2),
    pct_area    = round(100 * mean(abs(z_area)   > 3.5, na.rm = TRUE), 2)
  )

Utilizamos el puntaje de Iglewicz y Hoaglin (1993), que reemplaza la media por la mediana y la desviación estándar por la desviación absoluta mediana.

vivienda |>
  select(tipo, log_precio, log_area) |>
  pivot_longer(-tipo, names_to = "variable", values_to = "valor") |>
  ggplot(aes(tipo, valor)) +
  geom_boxplot(outlier.alpha = 0.25) +
  facet_wrap(~variable, scales = "free_y") +
  labs(x = NULL, y = "Valor (escala log)") +
  theme_minimal()
Distribución de precio y área en escala logarítmica, por tipo de vivienda.

Distribución de precio y área en escala logarítmica, por tipo de vivienda.

Los diagramas de caja muestran que, aun en escala logarítmica, ambas variables conservan una cola superior poblada. La diferencia de nivel entre ambos tipos justifica que el tipo de inmueble se incorpore como variable de control en el modelo hedónico.

2.6.2 Precio por metro cuadrado

Este indicador es el diagnóstico más informativo del conjunto, porque un error de digitación en el precio o en el área lo desplaza de inmediato mientras que una propiedad de lujo genuina lo mantiene dentro de un rango razonable para su segmento.

vivienda <- vivienda |> mutate(precio_m2 = preciom / areaconst)

vivienda |>
  group_by(estrato) |>
  summarise(n = n(),
            p05 = round(quantile(precio_m2, 0.05, na.rm = TRUE), 2),
            mediana = round(median(precio_m2, na.rm = TRUE), 2),
            p95 = round(quantile(precio_m2, 0.95, na.rm = TRUE), 2),
            .groups = "drop")

La tabla muestra que el precio mediano por metro cuadrado crece de forma monótona con el estrato, lo que confirma que la comparación debe realizarse dentro de cada estrato y no contra la mediana global. La amplitud entre los percentiles 5 y 95 dentro de cada estrato acota además el rango que puede considerarse normal.

vivienda <- vivienda |>
  group_by(estrato) |>
  mutate(razon_m2 = precio_m2 / median(precio_m2, na.rm = TRUE)) |>
  ungroup()

vivienda |>
  filter(razon_m2 > 4 | razon_m2 < 0.25) |>
  select(id, tipo, zona, estrato, preciom, areaconst, precio_m2, razon_m2) |>
  arrange(desc(razon_m2)) |>
  head(20)

Comparamos cada inmueble contra la mediana de precio por metro cuadrado de su propio estrato y no contra la mediana global, porque el valor del metro cuadrado varía sistemáticamente entre estratos y una comparación global marcaría como atípico todo el estrato 6. Un inmueble cuyo precio por metro cuadrado cuadruplica o no alcanza la cuarta parte del típico de su estrato es candidato a error de digitación.

2.6.3 Atípicos contextuales

Un inmueble puede tener precio y área normales y aun así ser anómalo dado el resto de sus características. Para detectarlo ajustamos un modelo hedónico y examinamos los residuos: el modelo predice el precio esperado de cada inmueble según sus atributos, y un residuo grande indica un precio que no se explica por ellos.

m_hedonico <- lm(log_precio ~ log_area + factor(estrato) + zona + tipo +
                   banios + habitaciones + parqueaderos,
                 data = vivienda)

vivienda <- vivienda |>
  mutate(
    resid_std = NA_real_,
    cook      = NA_real_
  )
filas <- as.integer(names(rstudent(m_hedonico)))
vivienda$resid_std[filas] <- rstudent(m_hedonico)
vivienda$cook[filas]      <- cooks.distance(m_hedonico)

cat("Atípicos contextuales (|residuo studentizado| > 3.5):",
    sum(abs(vivienda$resid_std) > 3.5, na.rm = TRUE), "\n")
## Atípicos contextuales (|residuo studentizado| > 3.5): 28
cat("Observaciones influyentes (D de Cook > 4/n):",
    sum(vivienda$cook > 4/nrow(vivienda), na.rm = TRUE), "\n")
## Observaciones influyentes (D de Cook > 4/n): 549
vivienda |>
  filter(abs(resid_std) > 3.5) |>
  select(id, tipo, zona, estrato, preciom, areaconst, banios, habitaciones,
         precio_m2, resid_std) |>
  arrange(desc(abs(resid_std))) |>
  head(15)

El residuo studentizado mide cuántas desviaciones típicas se aleja el precio observado del predicho por el modelo. La distancia de Cook complementa la lectura: identifica los registros que además de ser extremos alteran de forma apreciable las estimaciones del modelo.

2.6.4 Detección multivariada

vars_mv <- vivienda |>
  select(log_precio, log_area, banios, habitaciones, parqueaderos, estrato)

completos <- complete.cases(vars_mv)
mcd <- covMcd(vars_mv[completos, ], alpha = 0.75)

d2 <- mahalanobis(vars_mv[completos, ], center = mcd$center, cov = mcd$cov)
umbral <- qchisq(0.975, df = ncol(vars_mv))

vivienda$d_mahalanobis <- NA_real_
vivienda$d_mahalanobis[completos] <- d2

cat("Umbral chi-cuadrado (0.975,", ncol(vars_mv), "gl):", round(umbral, 2), "\n")
## Umbral chi-cuadrado (0.975, 6 gl): 14.45
cat("Registros por encima del umbral:", sum(d2 > umbral),
    paste0("(", round(100 * mean(d2 > umbral), 2), "%)"), "\n")
## Registros por encima del umbral: 1749 (21.25%)

La distancia robusta identifica 1749 registros por encima del umbral, equivalentes al 21.25% del conjunto. Conviene advertir que el umbral chi-cuadrado es válido bajo normalidad multivariada, condición que no se satisface aquí porque banios, habitaciones, parqueaderos y estrato son variables discretas; la distancia se interpreta por tanto como un ordenamiento de rareza multivariada y no como un contraste formal.

vivienda |>
  filter(!is.na(d_mahalanobis)) |>
  ggplot(aes(precio_m2, d_mahalanobis)) +
  geom_point(alpha = 0.2, size = 0.8) +
  geom_hline(yintercept = umbral, linetype = 2, colour = "red") +
  scale_y_log10() +
  labs(x = "Precio por m2 (millones)", y = "Distancia de Mahalanobis (log)") +
  theme_minimal()
Distancia de Mahalanobis robusta frente al precio por metro cuadrado.

Distancia de Mahalanobis robusta frente al precio por metro cuadrado.

La figura permite distinguir dos comportamientos entre los registros que superan el umbral: aquellos cuyo precio por metro cuadrado se mantiene dentro del rango habitual, que son extremos por combinación de atributos y no por precio, y aquellos que se desplazan simultáneamente en ambos ejes, que concentran la sospecha de error de digitación.

2.6.5 Triaje: ¿propiedad de lujo o error de digitación?

La decisión sobre cada registro extremo no se resuelve con un umbral estadístico sino evaluando su coherencia interna. Una mansión genuina es extrema en todas sus variables de forma simultánea y consistente; un error de digitación es extremo en una sola y contradice a las demás.

candidatos <- vivienda |>
  filter(abs(z_precio) > 3.5 | razon_m2 > 4 | razon_m2 < 0.25 |
           abs(resid_std) > 3.5) |>
  mutate(
    diagnostico = case_when(
      razon_m2 > 4  & areaconst < quantile(vivienda$areaconst, 0.5, na.rm = TRUE) ~
        "Sospecha de error en precio (alto) o area (bajo)",
      razon_m2 < 0.25 & preciom < quantile(vivienda$preciom, 0.25, na.rm = TRUE) ~
        "Sospecha de error en precio (bajo) o area (alto)",
      z_precio > 3.5 & estrato >= 5 & banios >= 4 & areaconst > 300 ~
        "Extremo coherente: segmento de lujo",
      TRUE ~ "Revisar individualmente"
    )
  )

candidatos |> count(diagnostico) |> arrange(desc(n))

El triaje clasifica los candidatos en tres grupos. Los clasificados como extremos coherentes corresponden al segmento de lujo y se conservan; los clasificados como sospecha de error concentran la revisión individual; el grupo residual requiere inspección caso por caso.

candidatos |>
  filter(diagnostico != "Extremo coherente: segmento de lujo") |>
  select(id, tipo, zona, estrato, preciom, areaconst, banios, habitaciones,
         precio_m2, razon_m2, resid_std, diagnostico) |>
  arrange(desc(razon_m2)) |>
  head(25)

2.6.6 Decisión y sensibilidad de las técnicas posteriores

vivienda <- vivienda |>
  mutate(
    atipico_univariado  = abs(z_precio) > 3.5 | abs(z_area) > 3.5,
    atipico_contextual  = abs(resid_std) > 3.5,
    atipico_multivar    = d_mahalanobis > umbral,
    atipico_cualquiera  = coalesce(atipico_univariado, FALSE) |
      coalesce(atipico_contextual, FALSE) |
      coalesce(atipico_multivar,   FALSE)
  )

vivienda |>
  summarise(across(starts_with("atipico_"), ~ sum(.x, na.rm = TRUE))) |>
  pivot_longer(everything(), names_to = "criterio", values_to = "registros") |>
  mutate(porcentaje = round(100 * registros / nrow(vivienda), 2))

La decisión adoptada es marcar y conservar, no eliminar. Los valores extremos coherentes constituyen el segmento alto del mercado, que es información sustantiva para una empresa inmobiliaria y no ruido: eliminarlos equivaldría a responder la pregunta de negocio sobre una versión censurada del mercado. Se mantienen por tanto en el conjunto, identificados mediante variables indicadoras.

Esta decisión tiene una consecuencia que debe tenerse presente: el análisis de componentes principales y el método k-means son especialmente sensibles a los valores extremos, adoptamos tres medidas para mitigar esto:

  1. Trabajar con log_precio y log_area en lugar de las variables originales, lo que comprime la cola derecha y reduce sustancialmente la influencia de los extremos.
  2. Ejecutar el ACP y el análisis de conglomerados en dos versiones, con y sin los registros marcados, y comparar la estructura obtenida. Si las componentes y los grupos coinciden, la conclusión es robusta; si difieren, se reporta como limitación.
  3. Contrastar k-means con métodos menos sensibles a los extremos, como PAM sobre distancia de Gower.
# Conjuntos para el contraste de sensibilidad de las secciones posteriores
vivienda_completa <- vivienda
vivienda_sin_atip <- vivienda |> filter(!atipico_cualquiera)

cat("Conjunto completo:", nrow(vivienda_completa), "observaciones\n")
## Conjunto completo: 8232 observaciones
cat("Conjunto sin atípicos:", nrow(vivienda_sin_atip), "observaciones\n")
## Conjunto sin atípicos: 6483 observaciones

Se dispone así de dos conjuntos paralelos que permitirán contrastar la sensibilidad del ACP y del análisis de conglomerados a la presencia de valores extremos. La diferencia de tamaño entre ambos, de 1751 observaciones, acota el alcance de esa comparación.

Categoría Criterio Tratamiento
Inconsistencias Violación de regla de dominio Eliminación
Errores de geocodificación Distancia elevada al centroide del barrio Exclusión solo del análisis espacial
Atípicos incoherentes Precio por m² incompatible con el estrato Marcados; revisión individual
Atípicos coherentes Extremos consistentes entre variables Conservados; segmento de lujo

3 Análisis exploratorio de datos

Con el conjunto ya depurado de inconsistencias y con los atípicos identificados, procedemos a caracterizar la oferta. El propósito de esta sección no es únicamente describir: cada bloque busca responder una pregunta que condiciona alguna de las tres técnicas que aplicaremos después. El análisis univariado numérico determina qué transformaciones requieren las variables antes del ACP; el univariado categórico establece si la cardinalidad de barrio permite incluirlo en un análisis de correspondencias; el bivariado verifica si existe la estructura de correlación que hace útil una reducción de dimensionalidad; y la exploración espacial anticipa si los conglomerados tendrán expresión geográfica.

3.1 Univariado numérico

asimetria <- function(x) {
  x <- x[!is.na(x)]
  mean((x - mean(x))^3) / sd(x)^3
}

vivienda |>
  select(preciom, areaconst, banios, habitaciones, parqueaderos, estrato,
         precio_m2, piso_torre, n_plantas) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  filter(!is.na(valor)) |>
  group_by(variable) |>
  summarise(
    n        = n(),
    minimo   = round(min(valor), 2),
    q1       = round(quantile(valor, 0.25), 2),
    mediana  = round(median(valor), 2),
    media    = round(mean(valor), 2),
    q3       = round(quantile(valor, 0.75), 2),
    maximo   = round(max(valor), 2),
    cv       = round(sd(valor) / mean(valor), 2),
    asimetria = round(asimetria(valor), 2),
    .groups = "drop"
  ) |>
  arrange(desc(asimetria))

La comparación entre media y mediana es el primer diagnóstico: cuando la media supera claramente a la mediana la distribución arrastra una cola derecha larga. Es lo que ocurre con preciom y areaconst, cuyos coeficientes de asimetría confirman que estamos ante distribuciones fuertemente sesgadas y no ante desviaciones menores de la simetría. El coeficiente de variación complementa la lectura indicando qué variables presentan mayor dispersión relativa.

vivienda |>
  select(preciom, areaconst, precio_m2) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  ggplot(aes(valor)) +
  geom_histogram(bins = 60, fill = "grey35") +
  facet_wrap(~variable, scales = "free") +
  labs(x = NULL, y = "Frecuencia") +
  theme_minimal()
Distribución de las variables continuas en escala original.

Distribución de las variables continuas en escala original.

vivienda |>
  transmute(log_precio, log_area, log_precio_m2 = log(precio_m2)) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  ggplot(aes(valor)) +
  geom_histogram(bins = 60, fill = "grey35") +
  facet_wrap(~variable, scales = "free") +
  labs(x = NULL, y = "Frecuencia") +
  theme_minimal()
Las mismas variables tras la transformación logarítmica.

Las mismas variables tras la transformación logarítmica.

El contraste entre ambas figuras justifica la decisión que ya habíamos anticipado al detectar atípicos. En escala original las distribuciones son marcadamente asimétricas y la mayor parte de las observaciones se comprime contra el eje; tras aplicar logaritmos adoptan una forma aproximadamente simétrica. Esto importa porque el ACP opera sobre la matriz de covarianzas o correlacione.

vivienda |>
  select(banios, habitaciones, parqueaderos, estrato) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  filter(!is.na(valor)) |>
  count(variable, valor) |>
  ggplot(aes(factor(valor), n)) +
  geom_col(fill = "grey35") +
  facet_wrap(~variable, scales = "free") +
  labs(x = NULL, y = "Frecuencia") +
  theme_minimal()
Distribución de las variables de conteo.

Distribución de las variables de conteo.

Tratar el estrato como variable continua en el ACP supone admitir que la distancia entre los estratos 1 y 2 equivale a la existente entre el 5 y el 6, supuesto discutible que se declara explícitamente al seleccionar las variables de entrada.

Las variables de conteo presentan un comportamiento distinto: no requieren transformación pero sí atención en cuanto a su naturaleza. banios, habitaciones y parqueaderos son conteos con rango reducido, y estrato es una variable ordinal con seis niveles.

3.2 Univariado categórico

vivienda |> count(tipo, sort = TRUE)   |> mutate(pct = round(100 * n / sum(n), 1))
vivienda |> count(zona, sort = TRUE)   |> mutate(pct = round(100 * n / sum(n), 1))
vivienda |> count(estrato, sort = TRUE)|> mutate(pct = round(100 * n / sum(n), 1))

La oferta se reparte entre casas y apartamentos en una proporción de 38.4 y 61.6, se concentra en las zonas Sur y Norte, y se distribuye entre los estratos 3 a 5. Los estratos 1 y 6 presentan un número reducido de registros, lo que anticipa estimaciones menos estables en esos segmentos.

bind_rows(
  vivienda |> count(categoria = as.character(tipo))    |> mutate(variable = "tipo"),
  vivienda |> count(categoria = as.character(zona))    |> mutate(variable = "zona"),
  vivienda |> count(categoria = as.character(estrato)) |> mutate(variable = "estrato")
) |>
  ggplot(aes(reorder(categoria, n), n)) +
  geom_col(fill = "grey35") +
  coord_flip() +
  facet_wrap(~variable, scales = "free") +
  labs(x = NULL, y = "Número de inmuebles") +
  theme_minimal()
Composición de la oferta por tipo, zona y estrato.

Composición de la oferta por tipo, zona y estrato.

Una categoría con muy pocos registros aparecerá muy alejada del origen en el mapa perceptual, no por poseer un perfil distintivo sino por la inestabilidad de su estimación, circunstancia que deberá tenerse presente al interpretar el análisis de correspondencias.

La composición por tipo y zona nos indica de entrada si el conjunto está balanceado o si alguna categoría domina la oferta, lo cual condiciona la interpretación del análisis de correspondencias

n_barrios <- n_distinct(vivienda$barrio)
cat("Número de barrios distintos:", n_barrios, "\n")
## Número de barrios distintos: 431
frec_barrio <- vivienda |> count(barrio, sort = TRUE)

frec_barrio |>
  summarise(
    mediana_registros = median(n),
    barrios_con_1     = sum(n == 1),
    barrios_menos_10  = sum(n < 10),
    barrios_menos_30  = sum(n < 30),
    barrios_50_o_mas  = sum(n >= 50)
  )
head(frec_barrio, 15)

El conjunto registra 431 barrios distintos, con una mediana de 3 inmuebles por barrio. Un número considerable de barrios aparece con menos de diez registros, cifra insuficiente para estimar un perfil estable en un análisis de correspondencias.

frec_barrio |>
  arrange(desc(n)) |>
  mutate(orden = row_number(), acumulado = cumsum(n) / sum(n)) |>
  ggplot(aes(orden, acumulado)) +
  geom_line() +
  geom_hline(yintercept = c(0.5, 0.8), linetype = 2, colour = "red") +
  labs(x = "Barrios ordenados por número de inmuebles",
       y = "Proporción acumulada de la oferta") +
  theme_minimal()
Concentración de la oferta: proporción acumulada de inmuebles por número de barrios.

Concentración de la oferta: proporción acumulada de inmuebles por número de barrios.

Incluir barrio en su forma original en un análisis de correspondencias produciría un mapa perceptual ilegible, con cientos de puntos superpuestos, y además inestable, porque las categorías con una o dos observaciones se proyectan en posiciones extremas determinadas por el azar. Por este motivo se construye una versión agrupada en el apartado de ingeniería de variables.

Este es el hallazgo de mayor consecuencia práctica de la sección. La variable barrio presenta una cardinalidad muy elevada y una distribución fuertemente concentrada: una minoría de barrios reúne la mayor parte de la oferta mientras que un número considerable aparece con muy pocos registros, lo cual tiene sentido con la distribuccion de oferta inmobiliaria en la cuidad de Cali.

3.3 Bivariado

3.3.1 Estructura de correlación entre variables numéricas

vars_num <- vivienda |>
  select(log_precio, log_area, banios, habitaciones, parqueaderos, estrato)

mat_cor <- cor(vars_num, use = "pairwise.complete.obs", method = "spearman")
round(mat_cor, 2)
##              log_precio log_area banios habitaciones parqueaderos estrato
## log_precio         1.00     0.82   0.78         0.44         0.67    0.71
## log_area           0.82     1.00   0.79         0.67         0.52    0.40
## banios             0.78     0.79   1.00         0.62         0.55    0.48
## habitaciones       0.44     0.67   0.62         1.00         0.24    0.03
## parqueaderos       0.67     0.52   0.55         0.24         1.00    0.59
## estrato            0.71     0.40   0.48         0.03         0.59    1.00
corrplot(mat_cor, method = "color", type = "upper",
         addCoef.col = "black", number.cex = 0.8,
         tl.col = "black", tl.srt = 45, diag = FALSE)
Matriz de correlación de Spearman entre variables numéricas.

Matriz de correlación de Spearman entre variables numéricas.

Por otra parte, la variable estrato presenta un patrón de correlación diferenciado, más asociado al precio que al tamaño, lo que sugiere la existencia de al menos dos dimensiones latentes: una de tamaño físico del inmueble y otra de nivel socioeconómico.

La lectura relevante para los objetivos es doble. Por un lado, la existencia de correlaciones apreciables entre log_area, banios, habitaciones y parqueaderos indica que estas variables comparten información: describen en buena medida la misma dimensión subyacente, que podríamos denominar tamaño del inmueble.

# Contraste de esfericidad de Bartlett: H0 la matriz de correlación es la identidad
cortest.bartlett(mat_cor, n = sum(complete.cases(vars_num)))
## $chisq
## [1] 39043.4
## 
## $p.value
## [1] 0
## 
## $df
## [1] 15
# Medida de adecuación muestral de Kaiser-Meyer-Olkin
KMO(mat_cor)
## Kaiser-Meyer-Olkin factor adequacy
## Call: KMO(r = mat_cor)
## Overall MSA =  0.79
## MSA for each item = 
##   log_precio     log_area       banios habitaciones parqueaderos      estrato 
##         0.74         0.77         0.90         0.75         0.94         0.70

Se obtiene un KMO global de 0.79, que según la convención señalada indica una adecuación buena. Debe advertirse que el contraste de Bartlett asume normalidad multivariada y es extremadamente sensible al tamaño muestral: con más de ocho mil observaciones su rechazo es prácticamente seguro, por lo que el KMO constituye el criterio informativo de los dos.

La prueba de esfericidad de Bartlett contrasta si la matriz de correlación difiere de la identidad; su rechazo indica que existen correlaciones que justifican la reducción. La medida KMO cuantifica qué proporción de la correlación entre variables es explicable por las restantes, con la convención habitual de que valores superiores a 0.7 indican adecuación buena, entre 0.5 y 0.7 mediocre, e inferiores a 0.5 desaconsejan el procedimiento

3.3.2 Relación entre precio y atributos

ggplot(vivienda, aes(log_area, log_precio)) +
  geom_point(alpha = 0.12, size = 0.7) +
  geom_smooth(method = "lm", se = FALSE, colour = "red") +
  facet_wrap(~estrato, labeller = label_both) +
  labs(x = "log(área construida)", y = "log(precio en millones)") +
  theme_minimal()
Relación entre área construida y precio, en escala logarítmica, por estrato.

Relación entre área construida y precio, en escala logarítmica, por estrato.

La relación entre área y precio es la más fuerte del conjunto y se mantiene aproximadamente lineal en escala logarítmica dentro de cada estrato, lo que confirma la adecuación de la transformación. Por consiguiente tiene sentido a segmentar el mercado por estrato en el análisis de conglomerados.

p1 <- ggplot(vivienda, aes(factor(estrato), log_precio)) +
  geom_boxplot(outlier.alpha = 0.15) +
  labs(x = "Estrato", y = "log(precio)") + theme_minimal()

p2 <- ggplot(vivienda, aes(reorder(zona, log_precio, median), log_precio)) +
  geom_boxplot(outlier.alpha = 0.15) +
  coord_flip() +
  labs(x = NULL, y = "log(precio)") + theme_minimal()

p3 <- ggplot(vivienda, aes(tipo, log_precio)) +
  geom_boxplot(outlier.alpha = 0.15) +
  labs(x = NULL, y = "log(precio)") + theme_minimal()

gridExtra::grid.arrange(p1, p2, p3, ncol = 1)
Distribución del precio por estrato, zona y tipo.

Distribución del precio por estrato, zona y tipo.

Los tres paneles muestran un gradiente monótono del precio con el estrato, diferencias apreciables entre zonas una vez ordenadas por mediana, y un nivel superior en casas frente a apartamentos atribuible principalmente a la diferencia de área. La amplitud de las cajas revela además que la dispersión interna de cada estrato es considerable, de modo que el estrato por sí solo no determina el precio.

vivienda |>
  group_by(zona) |>
  summarise(n = n(),
            precio_m2_mediano = round(median(precio_m2, na.rm = TRUE), 2),
            estrato_mediano   = median(estrato, na.rm = TRUE),
            area_mediana      = median(areaconst, na.rm = TRUE),
            .groups = "drop") |>
  arrange(desc(precio_m2_mediano))

Comparamos las zonas mediante el precio por metro cuadrado y no mediante el precio absoluto, porque el precio absoluto confunde dos efectos distintos: una zona puede resultar cara simplemente porque sus inmuebles son más grandes. El precio por metro cuadrado aísla el valor del suelo y de la localización, que es el componente sobre el que una empresa inmobiliaria puede tomar decisiones de inversión.

Ahora se revisa la composición entre las ofertas en las diferentes zonas

vivienda |>
  count(zona, tipo) |>
  group_by(zona) |>
  mutate(prop = n / sum(n)) |>
  ungroup() |>
  ggplot(aes(reorder(zona, prop * (tipo == "Apartamento")), prop, fill = tipo)) +
  geom_col() +
  coord_flip() +
  scale_fill_grey(start = 0.3, end = 0.7) +
  labs(x = NULL, y = "Proporción", fill = NULL) +
  theme_minimal()
Composición de la oferta por zona y tipo de vivienda.

Composición de la oferta por zona y tipo de vivienda.

Se evidencia al representar la composición de la oferta difiere entre zonas, que es exactamente el fenómeno que el análisis de correspondencias permitirá observar.

3.4 Exploración espacial

p_estrato <- vivienda |>
  filter(!is.na(longitud), !is.na(latitud)) |>
  ggplot(aes(longitud, latitud, colour = factor(estrato))) +
  geom_point(alpha = 0.4, size = 0.6) +
  coord_fixed() +
  scale_colour_viridis_d(name = "Estrato") +
  labs(x = NULL, y = NULL) +
  theme_minimal()

p_precio <- vivienda |>
  filter(!is.na(longitud), !is.na(latitud)) |>
  ggplot(aes(longitud, latitud, colour = log(precio_m2))) +
  geom_point(alpha = 0.4, size = 0.6) +
  coord_fixed() +
  scale_colour_viridis_c(name = "log(precio/m2)") +
  labs(x = NULL, y = NULL) +
  theme_minimal()

gridExtra::grid.arrange(p_estrato, p_precio, ncol = 2)
Distribución espacial de la oferta según estrato y precio por metro cuadrado.

Distribución espacial de la oferta según estrato y precio por metro cuadrado.

Ambas representaciones muestran que la oferta no se distribuye de forma homogénea sobre el territorio sino que forma agrupaciones claramente delimitadas, y que esas agrupaciones coinciden con gradientes de estrato y de precio por metro cuadrado.

muestra_mapa <- vivienda |>
  filter(!is.na(longitud), !is.na(latitud)) |>
  slice_sample(n = min(4000, nrow(vivienda)))

paleta <- colorNumeric("viridis", domain = log(muestra_mapa$precio_m2))

leaflet(muestra_mapa) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud,
    radius = 3, stroke = FALSE, fillOpacity = 0.6,
    color = ~paleta(log(precio_m2)),
    popup = ~paste0("<b>", barrio, "</b><br>",
                    "Tipo: ", tipo, "<br>",
                    "Estrato: ", estrato, "<br>",
                    "Precio: ", preciom, " millones<br>",
                    "Área: ", areaconst, " m2")
  ) |>
  addLegend("bottomright", pal = paleta, values = ~log(precio_m2),
            title = "log(precio/m2)", opacity = 0.8)

Mapa interactivo de la oferta.

Trabajamos sobre una muestra aleatoria de 4000 registros porque representar la totalidad de la oferta en un mapa interactivo degrada el rendimiento sin aportar información adicional.

Este resultado es relevante por dos motivos. En términos sustantivos confirma la segmentación espacial del mercado. En términos metodológicos anticipa que los conglomerados obtenidos a partir de atributos físicos y económicos, sin emplear las coordenadas como insumo, deberían presentar coherencia geográfica; que un agrupamiento construido sin información espacial se proyecte de forma ordenada sobre el territorio constituye una validación externa del resultado.

4 Ingeniería de variables

Esta sección consolida las variables derivadas y construye las que requieren las técnicas posteriores. Algunas fueron creadas durante la depuración log_precio, log_area, precio_m2, n_plantas y piso_torre y se documentan aquí para que el conjunto final quede definido en un solo lugar. Las restantes responden a necesidades específicas que el análisis exploratorio dejó en evidencia.

4.0.1 Transformaciones de escala

vivienda <- vivienda |>
  mutate(
    log_precio    = log(preciom),
    log_area      = log(areaconst),
    precio_m2     = preciom / areaconst,
    log_precio_m2 = log(precio_m2))

Debe declararse el supuesto implícito: al trabajar en escala logarítmica las diferencias se interpretan como proporciones y no como magnitudes absolutas. Ello significa considerar igualmente distantes dos inmuebles de 100 y 110 millones que dos de 500 y 550, lo cual resulta razonable en un mercado inmobiliario pero constituye una decisión sustantiva que debe hacerse explícita.

La transformación logarítmica de precio y área cumple tres funciones simultáneas: corrige la asimetría documentada en el análisis univariado, estabiliza la varianza y convierte la relación entre ambas variables en aproximadamente lineal

4.0.2 Indicadores de composición del inmueble

vivienda <- vivienda |>
  mutate(
    area_por_habitacion = areaconst / habitaciones,
    banios_por_habitac  = banios / habitaciones,
    tiene_parqueadero   = factor(if_else(parqueaderos > 0, "Si", "No")),
    espacios_totales    = habitaciones + banios
  )

vivienda |>
  summarise(across(c(area_por_habitacion, banios_por_habitac, espacios_totales),
                   list(mediana = ~round(median(.x, na.rm = TRUE), 2),
                        p05     = ~round(quantile(.x, 0.05, na.rm = TRUE), 2),
                        p95     = ~round(quantile(.x, 0.95, na.rm = TRUE), 2))))

Por tratarse de cocientes de variables ya presentes, estas variables introducen colinealidad y no deben incorporarse simultáneamente con sus componentes al análisis de componentes principales; su uso natural es el perfilamiento de los conglomerados.

Estas variables capturan información que las originales contienen solo de forma implícita. area_por_habitacion distingue entre un inmueble amplio y uno simplemente subdividido: dos viviendas de 120 m² con tres y seis habitaciones respectivamente pertenecen a segmentos de mercado distintos. banios_por_habitac es un indicador reconocido de nivel de acabados, ya que la proporción de baños por habitación tiende a crecer en los segmentos altos.

4.0.3 Segmentación de la Variable piso

vivienda <- vivienda |>
  mutate(
    piso_cat = case_when(
      is.na(piso_torre)   ~ NA_character_,
      piso_torre <= 4     ~ "1-4",
      piso_torre == 5     ~ "5",
      TRUE                ~ "6+"
    ),
    piso_cat = factor(piso_cat, levels = c("1-4", "5", "6+"), ordered = TRUE),
    
    segmento_edificio = case_when(
      tipo == "Casa"       ~ "Casa",
      is.na(piso_torre)    ~ NA_character_,
      piso_torre <= 5      ~ "Edificio bajo",
      TRUE                 ~ "Edificio alto"
    ),
    segmento_edificio = factor(segmento_edificio)
  )

vivienda |> count(segmento_edificio)

Trasladamos al conjunto principal la categorización que habíamos validado sobre el subconjunto de apartamentos, ya que hasta ahora piso_cat existía únicamente dentro del objeto viv. Añadimos además segmento_edificio, que traduce el hallazgo del quiebre entre los pisos 5 y 6 a una variable directamente interpretable en términos de negocio: distingue la vivienda unifamiliar, la torre sin ascensor y la torre con ascensor, que son tres productos inmobiliarios diferentes.

4.1 Agrupación de la variable barrio

# El umbral se elige observando el compromiso entre cobertura y número de categorías
map_dfr(c(20, 30, 50, 80, 100), function(u) {
  frecuentes <- frec_barrio |> filter(n >= u) |> pull(barrio)
  tibble(
    umbral         = u,
    n_categorias   = length(frecuentes) + 1,
    pct_en_otros   = round(100 * mean(!vivienda$barrio %in% frecuentes), 1)
  )
})

La tabla expone el compromiso entre ambos objetivos: al elevar el umbral disminuye el número de categorías y mejora la legibilidad del mapa, pero aumenta la proporción de oferta absorbida por la categoría residual. Se adopta un umbral de 50 registros por constituir el punto donde el número de categorías resulta manejable sin que la categoría residual supere el 34.5% del conjunto.

UMBRAL_BARRIO <- 50 

barrios_frecuentes <- frec_barrio |> filter(n >= UMBRAL_BARRIO) |> pull(barrio)

vivienda <- vivienda |>
  mutate(barrio_agr = factor(if_else(barrio %in% barrios_frecuentes,
                                     barrio, "Otros barrios")))

cat("Categorías resultantes:", nlevels(vivienda$barrio_agr), "\n")
## Categorías resultantes: 36
cat("Registros en la categoría residual:",
    sum(vivienda$barrio_agr == "Otros barrios"), "\n")
## Registros en la categoría residual: 2844

La agrupación reduce la variable a N categorías, de las cuales una es residual y concentra M registros. Debe señalarse una limitación: la categoría residual reúne barrios que no comparten más rasgo que su baja frecuencia en la muestra, por lo que su posición en el mapa de correspondencias carece de interpretación sustantiva y no debe leerse como un perfil.

La elección del umbral es un compromiso explícito entre dos objetivos opuestos. Un umbral bajo conserva más barrios identificados pero produce un mapa perceptual saturado y con categorías inestables; un umbral alto genera un mapa legible pero concentra demasiada oferta en una categoría que resulta difícil de interpretar.

4.2 Discretización para el análisis de correspondencias múltiples

vivienda <- vivienda |>
  mutate(
    precio_cat = cut(preciom,
                     breaks = quantile(preciom, probs = seq(0, 1, 0.25), na.rm = TRUE),
                     labels = c("Económico", "Medio-bajo", "Medio-alto", "Alto"),
                     include.lowest = TRUE, ordered_result = TRUE),
    area_cat   = cut(areaconst,
                     breaks = quantile(areaconst, probs = seq(0, 1, 0.25), na.rm = TRUE),
                     labels = c("Pequeño", "Mediano", "Amplio", "Muy amplio"),
                     include.lowest = TRUE, ordered_result = TRUE),
    estrato_cat = factor(estrato, levels = 1:6, ordered = TRUE)
  )

vivienda |> count(precio_cat)
vivienda |> count(area_cat)

Es necesario advertir que toda discretización implica pérdida de información: dos inmuebles situados a ambos lados de un punto de corte quedan asignados a categorías distintas pese a ser casi idénticos. Por este motivo la discretización se emplea exclusivamente en el análisis de correspondencias, mientras que el ACP y el análisis de conglomerados operan sobre las variables continuas.

El análisis de correspondencias múltiples admite únicamente variables categóricas, de modo que las variables continuas que deseemos incorporar deben discretizarse previamente. Utilizamos los cuartiles como puntos de corte por dos razones: garantizan categorías de tamaño equilibrado, lo que evita que una modalidad poco frecuente domine el mapa, y no requieren imponer umbrales externos que serían arbitrarios.

4.3 Consolidación y diccionario de variables derivadas

vivienda_final <- vivienda |>
  select(
    id, tipo, zona, barrio, barrio_agr,
    preciom, areaconst, precio_m2,
    log_precio, log_area, log_precio_m2,
    estrato, estrato_cat,
    banios, habitaciones, parqueaderos, tiene_parqueadero,
    area_por_habitacion, banios_por_habitac, espacios_totales,
    piso_torre, n_plantas, piso_cat, segmento_edificio,
    precio_cat, area_cat,
    longitud, latitud,
    atipico_univariado, atipico_contextual, atipico_multivar, atipico_cualquiera
  )

cat("Dimensiones del conjunto final:", nrow(vivienda_final), "x",
    ncol(vivienda_final), "\n")
## Dimensiones del conjunto final: 8232 x 32

El conjunto final reúne 8232 observaciones y 32 variables, de las cuales 15 son derivadas. Este es el objeto sobre el que se ejecutan las tres técnicas multivariadas de las secciones siguientes.

Variable derivada Construcción Destino
log_precio, log_area Logaritmo natural ACP, conglomerados
precio_m2, log_precio_m2 Precio dividido por área Comparación entre zonas y estratos
area_por_habitacion Área dividida por habitaciones Perfilamiento de conglomerados
banios_por_habitac Baños dividido por habitaciones Perfilamiento de conglomerados
espacios_totales Suma de baños y habitaciones Perfilamiento de conglomerados
tiene_parqueadero Indicador binario ACM, perfilamiento
piso_torre, n_plantas Separación semántica de piso Según subconjunto
piso_cat Cortes en 4 y 5 según el quiebre detectado ACM, distancia de Gower
segmento_edificio Casa / edificio bajo / edificio alto Interpretación de negocio
barrio_agr Barrios con al menos 50 registros ACM
precio_cat, area_cat Cuartiles ACM
estrato_cat Factor ordenado ACM, distancia de Gower

5 Análisis de Componentes Principales

El análisis de componentes principales persigue dos objetivos en este estudio. El primero es reducir la dimensionalidad del conjunto identificando un número menor de variables latentes que reproduzcan la mayor parte de la variabilidad observada. El segundo, y más relevante para la pregunta de negocio, es determinar qué dimensiones estructuran realmente la oferta inmobiliaria: si el mercado se organiza fundamentalmente en torno al tamaño del inmueble, al nivel socioeconómico de su localización, o a una combinación de ambos.

5.1 Selección de variables, estandarización y supuestos

La selección de variables no es libre: incorporar variables redundantes por construcción distorsiona la solución, ya que el ACP asigna mayor peso a las dimensiones representadas por más variables. Se aplican por tanto tres criterios de exclusión.

Variables excluidas del análisis de componentes principales y motivo de exclusión
Variable Decisión Motivo
precio_m2, log_precio_m2 Excluida Cociente exacto de log_precio y log_area; su inclusión duplicaría la información de ambas
espacios_totales Excluida Suma exacta de banios y habitaciones: introduce dependencia lineal perfecta y hace singular la matriz de correlación
area_por_habitacion, banios_por_habitac Excluida Cocientes de variables ya incluidas; generan colinealidad sin aportar dimensión nueva
piso_torre, n_plantas Excluida del ACP principal Definidas únicamente en un subconjunto: su inclusión forzaría eliminación por casos completos de más de la mitad de las observaciones
longitud, latitud Excluidas Describen localización, no atributos del inmueble; se reservan para validar espacialmente los conglomerados
preciom, areaconst Excluidas Sustituidas por sus transformaciones logarítmicas por la asimetría documentada en el análisis exploratorio

a exclusión de espacios_totales merece énfasis por ser la más grave de las tres primeras: al ser suma exacta de banios y habitaciones, su inclusión produce una matriz de correlación singular y el procedimiento devuelve una componente de varianza nula o un error numérico. La exclusión de piso_torre responde a un motivo distinto y se compensa mediante un análisis complementario exclusivo a apartamentos, que se presenta al final de la sección.

vars_acp <- c("log_precio", "log_area", "banios", "habitaciones",
              "parqueaderos", "estrato")

datos_acp <- vivienda_final |>
  select(all_of(vars_acp), id, tipo, zona, estrato_cat, atipico_cualquiera) |>
  drop_na(all_of(vars_acp))

X <- datos_acp |> select(all_of(vars_acp))

tibble(
  `Observaciones disponibles` = nrow(vivienda_final),
  `Observaciones completas`   = nrow(datos_acp),
  `Pérdida (%)` = round(100 * (1 - nrow(datos_acp) / nrow(vivienda_final)), 2),
  `Variables`   = ncol(X)
) |> tabla("Conjunto efectivo del análisis de componentes principales")
Conjunto efectivo del análisis de componentes principales
Observaciones disponibles Observaciones completas Pérdida (%) Variables
8.232 8.232 0 6

El conjunto efectivo comprende las 8232 observaciones disponibles: ninguna se pierde por ausencia de datos, ya que las seis variables seleccionadas quedaron completas tras el tratamiento de faltantes documentado en el apartado correspondiente. El análisis opera por tanto sobre la totalidad del conjunto depurado.

5.1.0.1 Verificación de ausencia de dependencia lineal

tibble(
  `Rango de la matriz`   = qr(scale(X))$rank,
  `Número de variables`  = ncol(X),
  `Determinante de R`    = round(det(cor(X)), 5),
  `Número de condición`  = round(kappa(cor(X)), 1)
) |> tabla("Diagnóstico de colinealidad de la matriz de correlación")
Diagnóstico de colinealidad de la matriz de correlación
Rango de la matriz Número de variables Determinante de R Número de condición
6 6 0,013 27,4

Estas cuatro cifras verifican que la matriz de correlación es invertible y numéricamente estable. El rango debe coincidir con el número de variables; si fuera inferior existiría al menos una dependencia lineal exacta. El determinante debe ser distinto de cero, y un valor muy próximo a cero advertiría de colinealidad severa.

5.1.0.2 Estandarización

X |>
  summarise(across(everything(), list(media = mean, varianza = var))) |>
  pivot_longer(everything(),
               names_to = c("Variable", ".value"),
               names_pattern = "(.*)_(media|varianza)") |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  tabla("Escala original de las variables antes de la estandarización")
Escala original de las variables antes de la estandarización
Variable media varianza
log_precio 5,838 0,450
log_area 4,918 0,440
banios 3,125 1,987
habitaciones 3,632 2,034
parqueaderos 1,487 1,532
estrato 4,637 1,059

El análisis se ejecuta sobre la matriz de correlaciones y no sobre la de covarianzas, lo que equivale a estandarizar previamente cada variable. La razón es que las variables operan en escalas incomparables: el logaritmo del precio se mueve en un rango de unas pocas unidades mientras que el número de habitaciones alcanza valores de un orden distinto. Si se trabajara sobre covarianzas, la variable de mayor varianza absoluta dominaría la primera componente por una razón puramente métrica, sin que ello reflejara mayor importancia sustantiva. La estandarización otorga a cada variable la misma varianza unitaria y hace que su contribución dependa de su estructura de correlación y no de sus unidades de medida.

5.1.0.3 Supuestos y adecuación

mat_cor_acp <- cor(X)

bartlett <- cortest.bartlett(mat_cor_acp, n = nrow(X))
kmo      <- KMO(mat_cor_acp)

tibble(
  Indicador = c("Bartlett: chi-cuadrado", "Bartlett: grados de libertad",
                "Bartlett: valor p", "KMO global"),
  Valor = c(round(bartlett$chisq, 1), bartlett$df,
            format.pval(bartlett$p.value, eps = 1e-16), round(kmo$MSA, 3))
) |> tabla("Contrastes de adecuación para el análisis de componentes principales")
Contrastes de adecuación para el análisis de componentes principales
Indicador Valor
Bartlett: chi-cuadrado 36000.6
Bartlett: grados de libertad 15
Bartlett: valor p < 1e-16
KMO global 0.77
tibble(Variable = names(kmo$MSAi), MSA = round(kmo$MSAi, 3)) |>
  arrange(MSA) |>
  tabla("Medida de adecuación muestral por variable")
Medida de adecuación muestral por variable
Variable MSA
estrato 0,663
habitaciones 0,674
log_precio 0,721
log_area 0,765
banios 0,869
parqueaderos 0,947

Los supuestos que sostienen la aplicación del procedimiento son los siguientes:

Relaciones aproximadamente lineales:El ACP solo captura estructura lineal. El análisis exploratorio verificó que la relación entre precio y área es lineal en escala logarítmica, condición que justifica la transformación aplicada.

Correlación suficiente entre variables: La prueba de Bartlett asume normalidad multivariada y su potencia crece con el tamaño muestral, de modo que con más de ocho mil observaciones el rechazo es prácticamente seguro y su valor informativo resulta escaso. El KMO constituye el criterio decisivo: la convención establece que valores superiores a 0.7 indican adecuación buena, entre 0.5 y 0.7 mediocre, e inferiores a 0.5 desaconsejan el procedimiento. El detalle por variable permite además identificar si alguna variable concreta se comporta de forma aislada respecto de las demás.

Tratamiento de estrato como variable continua: Estrato es una variable ordinal de seis niveles, y su inclusión en una matriz de correlaciones de Pearson supone admitir que la distancia entre los estratos 1 y 2 equivale a la existente entre el 5 y el 6. Dicha equivalencia no está garantizada por la definición administrativa de la estratificación. Se conserva la variable por su relevancia, es el principal determinante socioeconómico del precio y se contrasta la sensibilidad de la solución a este supuesto mediante correlaciones policóricas.

pos_estrato <- which(vars_acp == "estrato")

mat_mixta <- psych::mixedCor(data = X,
                             c = setdiff(seq_along(vars_acp), pos_estrato),
                             p = pos_estrato)$rho

# Los valores propios se obtienen directamente de la descomposición espectral
valores_pearson <- eigen(cor(X),      symmetric = TRUE, only.values = TRUE)$values
valores_mixta   <- eigen(mat_mixta,   symmetric = TRUE, only.values = TRUE)$values

tibble(
  Componente    = paste0("PC", seq_along(valores_pearson)),
  `Pearson`     = round(valores_pearson, 3),
  `Mixta`       = round(valores_mixta, 3),
  `Diferencia`  = round(valores_mixta - valores_pearson, 3),
  `% var. Pearson` = round(100 * valores_pearson / sum(valores_pearson), 2),
  `% var. Mixta`   = round(100 * valores_mixta   / sum(valores_mixta), 2)
) |> tabla("Valores propios bajo correlación de Pearson y bajo correlación mixta con estrato tratado como ordinal")
Valores propios bajo correlación de Pearson y bajo correlación mixta con estrato tratado como ordinal
Componente Pearson Mixta Diferencia % var. Pearson % var. Mixta
PC1 3,668 3,711 0,043 61,14 61,85
PC2 1,269 1,282 0,013 21,16 21,37
PC3 0,471 0,461 -0,011 7,86 7,68
PC4 0,274 0,263 -0,011 4,56 4,38
PC5 0,224 0,207 -0,017 3,73 3,45
PC6 0,094 0,076 -0,017 1,56 1,27

Los valores propios obtenidos bajo ambas matrices son prácticamente idénticos: 3.668 frente a 3.711 en la primera componente y 1.269 frente a 1.282 en la segunda, con diferencias inferiores a 0.05 en todos los casos. El número de componentes con valor propio superior a la unidad no varía, y la varianza acumulada retenida pasa del 82.29% al 83.22%. Se concluye que la solución es robusta al tratamiento de estrato como variable continua.

5.1.1 Criterio de retención de componentes

acp <- prcomp(X, center = TRUE, scale. = TRUE)

varianza <- tibble(
  Componente     = paste0("PC", seq_along(acp$sdev)),
  `Valor propio` = round(acp$sdev^2, 3),
  `% varianza`   = round(100 * acp$sdev^2 / sum(acp$sdev^2), 2),
  `% acumulado`  = round(cumsum(100 * acp$sdev^2 / sum(acp$sdev^2)), 2)
)

varianza |> tabla("Varianza explicada por componente")
Varianza explicada por componente
Componente Valor propio % varianza % acumulado
PC1 3,668 61,14 61,14
PC2 1,269 21,16 82,29
PC3 0,471 7,86 90,15
PC4 0,274 4,56 94,71
PC5 0,224 3,73 98,44
PC6 0,094 1,56 100,00

La primera componente explica el 61.14% de la varianza total y la segunda el 21.16%, acumulando entre ambas el 82.29%. El descenso posterior es pronunciado: la tercera componente aporta únicamente un 7.86% y su valor propio, 0.471, se sitúa muy por debajo de la unidad. La estructura sugiere por tanto dos dimensiones dominantes claramente separadas del resto.

varianza |>
  mutate(k = row_number()) |>
  ggplot(aes(k, `Valor propio`)) +
  geom_line() +
  geom_point(size = 2.5) +
  geom_hline(yintercept = 1, linetype = 2, colour = "red") +
  scale_x_continuous(breaks = 1:nrow(varianza)) +
  labs(x = "Componente", y = "Valor propio") +
  theme_minimal()
Gráfico de sedimentación con la regla de Kaiser señalada.

Gráfico de sedimentación con la regla de Kaiser señalada.

El gráfico confirma la lectura anterior: la curva desciende de forma abrupta entre la primera y la tercera componente y se aplana a partir de ahí. El codo se sitúa en la tercera posición, lo que equivale a retener las dos primeras.

paralelo <- fa.parallel(X, fa = "pc", n.iter = 100,
                        show.legend = TRUE, plot = TRUE,
                        main = "Análisis paralelo de Horn")
Análisis paralelo de Horn: valores propios observados frente a los generados por datos aleatorios.

Análisis paralelo de Horn: valores propios observados frente a los generados por datos aleatorios.

## Parallel analysis suggests that the number of factors =  NA  and the number of components =  2

El análisis paralelo de Horn confirma la retención de dos componentes: los valores propios observados en las dos primeras posiciones superan holgadamente a los generados a partir de datos aleatorios de igual dimensión, mientras que a partir de la tercera quedan por debajo. Al tratarse del único criterio que contrasta la estructura observada frente a una hipótesis de ausencia de estructura, su coincidencia refuerza la solidez de la decisión.

Comparación de criterios de retención
Criterio Componentes sugeridas Fundamento
Kaiser (valor propio > 1) 2 Una componente debe explicar al menos tanta varianza como una variable original estandarizada
Varianza acumulada >= 70% 2 Umbral convencional de información retenida
Codo del gráfico de sedimentación NA Punto donde la pendiente de la curva se aplana; criterio visual
Análisis paralelo de Horn 2 Retiene las componentes cuyo valor propio supera el obtenido con datos aleatorios de igual dimensión

Se retienen dos componentes, que explican conjuntamente el 82.29% de la varianza total. Los criterios de Kaiser, de varianza acumulada y de análisis paralelo de Horn coinciden en ese valor.

5.2 Cargas, biplot e interpretación

5.2.1 Matriz de cargas

K <- max(2, sum(acp$sdev^2 > 1))   # componentes retenidas

acp$rotation[, 1:K] |>
  as.data.frame() |>
  rownames_to_column("Variable") |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  tabla("Cargas de las variables sobre las componentes retenidas")
Cargas de las variables sobre las componentes retenidas
Variable PC1 PC2
log_precio 0,484 0,175
log_area 0,463 -0,219
banios 0,460 -0,186
habitaciones 0,285 -0,684
parqueaderos 0,386 0,264
estrato 0,331 0,591

Las cargas nos permiten ver el peso que tiene cada variable original en la contruscción de cada compoenente y constituyen la base de su interpretación. Se puede notar que las variables con mas peso en el compoenente PC1 son log_precio, log_area y banios y para PC2 la variable habitaciones con una relacion inversa definen mayoritariamente el componente.

acp$rotation[, 1:2] |>
  as.data.frame() |>
  rownames_to_column("Variable") |>
  pivot_longer(-Variable, names_to = "Componente", values_to = "Carga") |>
  ggplot(aes(reorder(Variable, Carga), Carga)) +
  geom_col(fill = "grey35") +
  geom_hline(yintercept = 0) +
  coord_flip() +
  facet_wrap(~Componente) +
  labs(x = NULL, y = "Carga") +
  theme_minimal()
Cargas de cada variable sobre las dos primeras componentes.

Cargas de cada variable sobre las dos primeras componentes.

La representación gráfica evidencia el contraste que define la segunda componente: habitaciones se sitúa en el extremo negativo mientras que estrato y parqueaderos ocupan el positivo, en tanto que en la primera componente todas las variables comparten signo. Un patrón de cargas del mismo signo identifica una dimensión de tamaño general; un patrón de signos opuestos identifica un contraste entre grupos de variables.

5.2.2 Contribuciones y calidad de representación

grid.arrange(
  fviz_contrib(acp, choice = "var", axes = 1) + labs(title = "Contribución a PC1"),
  fviz_contrib(acp, choice = "var", axes = 2) + labs(title = "Contribución a PC2"),
  ncol = 2)
Contribución de cada variable a las dos primeras componentes.

Contribución de cada variable a las dos primeras componentes.

La contribución indica qué proporción de la varianza de cada componente aporta cada variable; la línea roja de referencia marca el valor esperado bajo contribución uniforme, de modo que las variables que la superan son las que definen esa componente.

get_pca_var(acp)$cos2[, 1:K] |>
  as.data.frame() |>
  rownames_to_column("Variable") |>
  mutate(across(where(is.numeric), ~ round(.x, 3)),
         `Suma acumulada` = round(rowSums(across(where(is.numeric))), 3)) |>
  tabla("Calidad de representación de cada variable (cos2) en el espacio retenido")
Calidad de representación de cada variable (cos2) en el espacio retenido
Variable Dim.1 Dim.2 Suma acumulada
log_precio 0,860 0,039 0,899
log_area 0,785 0,061 0,846
banios 0,776 0,044 0,820
habitaciones 0,298 0,594 0,892
parqueaderos 0,547 0,088 0,635
estrato 0,401 0,443 0,844

El indicador cos² mide la calidad de representación de cada variable en el espacio reducido valores cercanos a uno indican que la variable queda bien reproducida por las componentes retenidas, mientras que un valor bajo advierte de que esa variable conserva información propia que la reducción ha descartado. para nuestro caso todas las variables cuentan con un valor de Cos2 mayor al 0.60 mostrando una correcta reproduccion en los componentes

5.2.3 Representación conjunta

fviz_pca_var(acp, col.var = "cos2", repel = TRUE,
             gradient.cols = c("grey70", "steelblue", "darkblue")) +
  labs(title = NULL) +
  theme_minimal()
Círculo de correlaciones de las variables sobre el primer plano factorial.

Círculo de correlaciones de las variables sobre el primer plano factorial.

Las variables log_precio, log_area y banios se proyectan próximas entre sí y cerca de la circunferencia unidad, lo que confirma tanto su elevada correlación mutua como su buena representación en el plano. Habitaciones y estrato se sitúan en direcciones prácticamente ortogonales a ese grupo, lo que corrobora que aportan información distinta y sostiene la existencia de dimensiones diferenciadas.

tibble(
  PC1 = acp$x[, 1],
  PC2 = acp$x[, 2],
  Estrato = factor(datos_acp$estrato),
  Zona    = datos_acp$zona
) |>
  slice_sample(n = min(3000, nrow(datos_acp))) |>
  ggplot(aes(PC1, PC2, colour = Estrato)) +
  geom_point(alpha = 0.35, size = 0.8) +
  scale_colour_viridis_d() +
  labs(x = paste0("PC1 (", varianza$`% varianza`[1], "%)"),
       y = paste0("PC2 (", varianza$`% varianza`[2], "%)")) +
  theme_minimal()
Proyección de los individuos sobre el primer plano factorial según estrato.

Proyección de los individuos sobre el primer plano factorial según estrato.

El circulo de correlaciones permite leer la proximidad entre los vectores que indica correlaccion positiva, la oposición que indica correlación negativa, y la longitud del vector que refleja la calidad de la representacion en el plano

5.2.4 Denominación e interpretación de las componentes

La primera componente presenta cargas positivas y de magnitud comparable en log_precio (0.484), log_area (0.463), banios (0.460) y parqueaderos (0.386), configurando una dimensión de TAMAÑO Y CAPACIDAD que resume el volumen de espacio habitable ofrecido y explica el 61.14% de la varianza. La segunda componente, con el 21.16%, opone habitaciones (−0.684) a estrato (0.591) y parqueaderos (0.264), y se interpreta como una dimensión de NIVEL SOCIOECONÓMICO que distingue el inmueble valorado por su localización del valorado por su número de espacios. La estructura confirma la hipótesis formulada a partir de la matriz de correlación: el tamaño físico y el nivel de la localización constituyen ejes separables del mercado.

5.2.5 Rotación complementaria

acp_rot <- principal(X, nfactors = K, rotate = "varimax", scores = FALSE)

acp_rot$loadings[, 1:K] |>
  unclass() |>
  as.data.frame() |>
  rownames_to_column("Variable") |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  tabla("Cargas tras rotación varimax")
Cargas tras rotación varimax
Variable RC1 RC2
log_precio 0,827 0,465
log_area 0,502 0,771
banios 0,523 0,739
habitaciones -0,099 0,940
parqueaderos 0,752 0,265
estrato 0,915 -0,082

La rotación varimax redistribuye la varianza entre las componentes retenidas buscando que cada variable cargue fuertemente sobre una sola de ellas, lo que suele facilitar la interpretación. Se presenta por tanto como apoyo interpretativo y no como sustituto de la solución principal.

5.2.6 Análisis complementario sobre el subconjunto de apartamentos

datos_apto <- vivienda_final |>
  filter(tipo == "Apartamento") |>
  select(all_of(vars_acp), piso_torre) |>
  drop_na()

acp_apto <- prcomp(datos_apto, center = TRUE, scale. = TRUE)

tibble(
  Componente     = paste0("PC", seq_along(acp_apto$sdev)),
  `Valor propio` = round(acp_apto$sdev^2, 3),
  `% varianza`   = round(100 * acp_apto$sdev^2 / sum(acp_apto$sdev^2), 2),
  `% acumulado`  = round(cumsum(100 * acp_apto$sdev^2 / sum(acp_apto$sdev^2)), 2)
) |> tabla("Varianza explicada incorporando la variable piso_torre (apartamentos)")
Varianza explicada incorporando la variable piso_torre (apartamentos)
Componente Valor propio % varianza % acumulado
PC1 4,245 60,64 60,64
PC2 0,978 13,98 74,62
PC3 0,859 12,28 86,90
PC4 0,355 5,07 91,96
PC5 0,285 4,08 96,04
PC6 0,199 2,84 98,88
PC7 0,078 1,12 100,00
acp_apto$rotation[, 1:2] |>
  as.data.frame() |>
  rownames_to_column("Variable") |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  tabla("Cargas sobre las dos primeras componentes (apartamentos)")
Cargas sobre las dos primeras componentes (apartamentos)
Variable PC1 PC2
log_precio 0,460 -0,062
log_area 0,448 0,086
banios 0,430 0,100
habitaciones 0,257 0,393
parqueaderos 0,408 -0,035
estrato 0,395 -0,089
piso_torre 0,116 -0,903

Este análisis recupera la variable piso_torre, excluida del ACP principal por estar definida únicamente en apartamentos. Su propósito es determinar si el nivel dentro de la torre constituye una dimensión propia de la estructura de la oferta o si queda absorbido por las dimensiones ya identificadas. Como se nota en la tabla la componente piso_torre posee una carga elevada con sentido negativo lo que indica que la componente de verticalidad es un eje independiente en el mercado de apartamentos.

5.2.7 Sensibilidad a los valores extremos

datos_sin <- datos_acp |> filter(!atipico_cualquiera)
acp_sin   <- prcomp(datos_sin |> select(all_of(vars_acp)), center = TRUE, scale. = TRUE)

# Las componentes están definidas salvo el signo: se comparan valores absolutos
comparacion <- tibble(
  Componente = paste0("PC", 1:K),
  `% varianza (completo)`     = round(100 * acp$sdev[1:K]^2 / sum(acp$sdev^2), 2),
  `% varianza (sin atípicos)` = round(100 * acp_sin$sdev[1:K]^2 / sum(acp_sin$sdev^2), 2),
  `Congruencia de cargas`     = round(
    sapply(1:K, function(k) abs(cor(acp$rotation[, k], acp_sin$rotation[, k]))), 3)
)

comparacion |> tabla("Comparación de la solución con y sin los registros marcados como atípicos")
Comparación de la solución con y sin los registros marcados como atípicos
Componente % varianza (completo) % varianza (sin atípicos) Congruencia de cargas
PC1 61,14 69,72 0,953
PC2 21,16 16,03 0,993

La congruencia de cargas mide la correlación entre el vector de cargas de una misma componente en ambas soluciones.Valores superiores a 0.95 permiten concluir que la estructura factorial es estable y que los valores extremos no determinan la orientación de las componentes

5.2.8 Síntesis del análisis de componentes principales

Resumen del análisis de componentes principales
Elemento Resultado
Variables incluidas log_precio, log_area, banios, habitaciones, parqueaderos, estrato
Observaciones analizadas 8.232
Componentes retenidas 2
Varianza acumulada retenida 82.3%
Criterio de retención principal Análisis paralelo de Horn
Supuesto más frágil Tratamiento de estrato como variable continua, contrastado con correlaciones policóricas
Estabilidad ante valores extremos Estable: congruencia de cargas de 0.953 en PC1 y 0.993 en PC2, ambas por encima del umbral de 0.95⟩

La solución retenida reproduce el 82.29% de la variabilidad original mediante dos dimensiones ortogonales, y se ha mostrado estable tanto frente al tratamiento de estrato como variable ordinal como frente a la exclusión de los registros marcados como atípicos. Estas puntuaciones constituyen la entrada del análisis de conglomerados desarrollado en el apartado siguiente.

# Puntuaciones de los individuos sobre las componentes retenidas
scores_acp <- acp$x[, 1:K] |>
  as_tibble() |>
  bind_cols(datos_acp |> select(id, tipo, zona, estrato, atipico_cualquiera))

tibble(
  `Observaciones`         = nrow(scores_acp),
  `Componentes retenidas` = K,
  `Variables de apoyo`    = ncol(scores_acp) - K,
  `Varianza retenida (%)` = round(sum(varianza$`% varianza`[1:K]), 2),
  `Varianza descartada (%)` = round(100 - sum(varianza$`% varianza`[1:K]), 2)
) |> tabla("Estructura del conjunto de puntuaciones para el análisis de conglomerados")
Estructura del conjunto de puntuaciones para el análisis de conglomerados
Observaciones Componentes retenidas Variables de apoyo Varianza retenida (%) Varianza descartada (%)
8.232 2 5 82,3 17,7

6 Análisis de Conglomerados

El objetivo de esta sección es agrupar las propiedades residenciales en segmentos homogéneos que permitan comprender las dinámicas de la oferta en distintas partes de la ciudad y distintos estratos socioeconómicos. A diferencia del análisis de componentes principales, que reduce el número de variables, el análisis de conglomerados reduce el número de individuos a un conjunto reducido de perfiles representativos.

6.1 Preparación y elección de la entrada

6.1.1 Alternativas consideradas

Variantes de entrada consideradas para el análisis de conglomerados
Variante Entrada Distancia Algoritmo Ventaja Limitación
A. Puntuaciones factoriales PC1 y PC2 del ACP Euclidiana k-medias Elimina la redundancia entre variables correlacionadas Descarta la varianza no retenida por las componentes
B. Variables originales estandarizadas log_precio, log_area, banios, habitaciones, parqueaderos, estrato Euclidiana k-medias Conserva la totalidad de la varianza original La dimensión de tamaño se contabiliza cuatro veces
C. Variables mixtas con distancia de Gower Las anteriores más tipo y estrato como factor ordenado Gower PAM sobre muestra Admite variables categóricas y es menos sensible a valores extremos Requiere trabajar sobre una muestra por coste computacional

Se ejecutan las tres variantes y se comparan las particiones resultantes. Este diseño convierte la elección de la medida de distancia en una decisión respaldada por evidencia

6.1.2 Restricción computacional

Coste de calcular la matriz completa de distancias
Observaciones Pares de la matriz de distancias Memoria aproximada (MB)
8.232 33.878.796 258

Se adopta por tanto una estrategia mixta: el método k-medias, cuyo coste es lineal, se aplica sobre todas las observaciones, mientras que los métodos basados en distancias se aplican sobre una muestra aleatoria de 2500 observaciones. Esta decisión es metodológicamente aceptable porque el objetivo del agrupamiento es identificar estructura, y una muestra aleatoria de ese tamaño reproduce la estructura de la población con precisión suficiente.

# Variante A: puntuaciones factoriales
entrada_A <- scores_acp |> select(all_of(paste0("PC", 1:K))) |> as.matrix()

# Variante B: variables originales estandarizadas
entrada_B <- scale(datos_acp |> select(all_of(vars_acp)))

# Variante C: variables mixtas sobre muestra
datos_C <- datos_acp |>
  transmute(log_precio, log_area, banios, habitaciones, parqueaderos,
            estrato_cat = factor(estrato, levels = 1:6, ordered = TRUE),
            tipo        = factor(tipo)) |>
  drop_na()

idx_muestra <- sample(nrow(datos_C), min(2500, nrow(datos_C)))
entrada_C   <- datos_C[idx_muestra, ]

d_gower <- daisy(entrada_C, metric = "gower")

tibble(
  Variante = c("A", "B", "C"),
  Observaciones = c(nrow(entrada_A), nrow(entrada_B), nrow(entrada_C)),
  Variables = c(ncol(entrada_A), ncol(entrada_B), ncol(entrada_C))
) |> tabla("Dimensiones de cada entrada")
Dimensiones de cada entrada
Variante Observaciones Variables
A 8.232 2
B 8.232 6
C 2.500 7

Las tres entradas quedan definidas con dimensiones distintas: las variantes A y B operan sobre las 8232 observaciones con dos y seis variables respectivamente, mientras que la variante C se restringe a una muestra de 2500 registros descritos por siete variables, dos de ellas categóricas.

6.2 Elección de la medida de distancia

La distancia euclidiana mide la separación en línea recta en el espacio de las variables y es la métrica implícita del método k-medias, que minimiza la suma de distancias euclidianas al cuadrado dentro de cada grupo. Exige que todas las variables sean numéricas y estén en escalas comparables, condición que se satisface tras la estandarización. Su principal limitación en este contexto es la sensibilidad a los valores extremos, ya que al elevar las diferencias al cuadrado penaliza desproporcionadamente las observaciones alejadas.

La distancia de Gower se calcula como la media de las disimilitudes parciales de cada variable, empleando en cada caso el tratamiento apropiado a su naturaleza: diferencia absoluta normalizada por el rango para las continuas, coincidencia o discrepancia para las nominales, y diferencia de rangos para las ordinales. Su ventaja decisiva es que admite variables categóricas, lo que permite incorporar tipo y tratar estrato como ordinal sin imponer el supuesto de equiespaciamiento que se declaró en el análisis de componentes principales.

Debe advertirse una particularidad de la variante A. Las puntuaciones factoriales no se reescalan antes de calcular distancias, de modo que la primera componente pesa más que la segunda en proporción a la varianza que explica. Esta decisión es deliberada traduce la importancia relativa de cada dimensión pero constituye una elección implícita distinta de la que resultaría al estandarizar también las puntuaciones.

6.3 Determinación del número de grupos

sub_diag <- entrada_A[sample(nrow(entrada_A), min(3000, nrow(entrada_A))), ]

g_codo <- fviz_nbclust(sub_diag, kmeans, method = "wss", k.max = 10, nstart = 25) +
  labs(title = NULL, subtitle = "Método del codo")

g_sil <- fviz_nbclust(sub_diag, kmeans, method = "silhouette", k.max = 10, nstart = 25) +
  labs(title = NULL, subtitle = "Anchura media de silueta")

grid.arrange(g_codo, g_sil, ncol = 2)
Suma de cuadrados intragrupo según el número de conglomerados.

Suma de cuadrados intragrupo según el número de conglomerados.

sub_gap <- entrada_A[sample(nrow(entrada_A), 1000), ]

fviz_nbclust(sub_gap, kmeans, method = "gap_stat", k.max = 8,
             nboot = 50, nstart = 25) +
  labs(title = NULL, subtitle = "Estadístico de brecha")
Estadístico de brecha de Tibshirani.

Estadístico de brecha de Tibshirani.

Criterios empleados para determinar el número de conglomerados
Criterio Fundamento Naturaleza
Codo (suma de cuadrados intragrupo) Identifica el punto a partir del cual añadir grupos deja de reducir apreciablemente la heterogeneidad interna Visual, sujeto a juicio
Anchura media de silueta Mide en qué grado cada observación se parece más a su propio grupo que al más próximo; se maximiza Cuantitativo
Estadístico de brecha Compara la dispersión intragrupo observada con la esperada bajo una distribución de referencia sin estructura Cuantitativo con referencia nula
Interpretabilidad sustantiva Un segmento sin lectura de negocio no es útil aunque optimice un criterio numérico Cualitativo

La anchura media de silueta alcanza su máximo en tres conglomerados (0.455) y desciende un 14.7% al pasar a cuatro (0.388), manteniéndose después en una meseta sin recuperación; se trata de la señal más definida del conjunto.El método del codo resulta compatible con esa lectura pero no discrimina: la suma de cuadrados intragrupo decae de forma progresiva reducciones sucesivas del 50.7%, 26.4% y 20.8% sin presentar un quiebre identificable.Que el estadístico de brecha no logre rechazar la hipótesis de ausencia de estructura indica que las observaciones no forman agrupaciones naturalmente separadas, sino que se distribuyen como una nube continua en el espacio, dicho comportamiento es coherente con la naturaleza de los datos estudiados al ser las viviendas objetos con atributos como área, numero de espacios, precio varían de forma gradual y no admiten discontinuidades que permitan la delimitación de poblaciones.

Bajo esta lectura se adopta una partición en tres conglomerados, por ser el valor que maximiza la anchura media de silueta y es compatible con el criterio del codo

K_GRUPOS <- 3 

Comprobamos utilizando un dendrograma

hc <- hclust(d_gower, method = "ward.D2")

plot(hc, labels = FALSE, hang = -1,
     main = NULL, xlab = "Observaciones", ylab = "Altura", sub = "")
rect.hclust(hc, k = K_GRUPOS, border = "firebrick")
Dendrograma del agrupamiento jerárquico sobre distancia de Gower (muestra de 2500 observaciones).

Dendrograma del agrupamiento jerárquico sobre distancia de Gower (muestra de 2500 observaciones).

calculamos los valores exactos en lugar de leer solo gráficamente los resultados del dendrograma

alturas <- rev(hc$height)[1:10]
tibble(
  `Grupos resultantes` = 2:11,
  `Altura de fusión`   = round(alturas, 3),
  `Salto`              = round(c(NA, -diff(alturas)), 3)
) |> tabla("Alturas de fusión en las últimas agregaciones del dendrograma")
Alturas de fusión en las últimas agregaciones del dendrograma
Grupos resultantes Altura de fusión Salto
2 10,321 NA
3 4,862 5,459
4 3,973 0,888
5 2,825 1,148
6 2,742 0,083
7 1,829 0,913
8 1,566 0,262
9 1,488 0,078
10 1,255 0,233
11 1,251 0,004
tabla_corte <- table(Corte_k2 = cutree(hc, k = 2), Tipo = entrada_C$tipo)
tabla_corte
##         Tipo
## Corte_k2 Apartamento Casa
##        1        1528    0
##        2           0  972
round(100 * prop.table(tabla_corte, 1), 1)
##         Tipo
## Corte_k2 Apartamento Casa
##        1         100    0
##        2           0  100
# Concordancia entre el corte en dos grupos y la variable tipo
fpc::cluster.stats(d_gower, cutree(hc, 2), as.integer(entrada_C$tipo))$corrected.rand
## [1] 1

Con esto en cuenta el dendrograma construido sobre distancia de Gower favorece de manera nítida la partición en dos grupos. El examen de la partición en dos grupos revela, sin embargo, que dicha división carece de valor informativo. El corte reproduce exactamente la variable tipo, con 945 casas asignadas a un conglomerado y 1555 apartamentos al otro sin una sola observación cruzada, lo que arroja un índice de Rand ajustado igual a la unidad. La distancia de Gower incorpora tipo como variable nominal, y una variable binaria perfectamente bimodal produce la separación más limpia que el algoritmo puede identificar, situándola en consecuencia en el nivel superior de la jerarquía. La partición en dos grupos devuelve por tanto una distinción ya conocida y disponible en la base de datos, sin aportar conocimiento nuevo sobre la estructura del mercado.

Se adopta en consecuencia una partición en tres conglomerados, por ser el primer nivel de agregación que aporta información no contenida en las variables originales: mantiene las casas como un segmento diferenciado y subdivide la oferta de apartamentos en dos perfiles distintos.

6.4 Ajuste de los modelos

# Variante A: k-medias sobre puntuaciones factoriales
km_A <- kmeans(entrada_A, centers = K_GRUPOS, nstart = 50, iter.max = 100)

# Variante B: k-medias sobre variables originales estandarizadas
km_B <- kmeans(entrada_B, centers = K_GRUPOS, nstart = 50, iter.max = 100)

# Variante C: PAM sobre distancia de Gower (muestra)
pam_C <- pam(d_gower, k = K_GRUPOS, diss = TRUE)

# La silueta exige que las etiquetas y la matriz de distancias correspondan a las
# mismas observaciones: se extrae un único índice y se aplica a ambos argumentos.
idx_sil <- sample(nrow(entrada_A), 2000)
d_sil_A <- dist(entrada_A[idx_sil, ])
d_sil_B <- dist(entrada_B[idx_sil, ])

sil_A <- silhouette(km_A$cluster[idx_sil], d_sil_A)
sil_B <- silhouette(km_B$cluster[idx_sil], d_sil_B)

tibble(
  Variante = c("A. k-medias sobre componentes",
               "B. k-medias sobre variables originales",
               "C. PAM sobre Gower (muestra)"),
  Observaciones = c(nrow(entrada_A), nrow(entrada_B), nrow(entrada_C)),
  `Varianza explicada (%)` = c(
    round(100 * km_A$betweenss / km_A$totss, 1),
    round(100 * km_B$betweenss / km_B$totss, 1),
    NA_real_),
  `Silueta media` = round(c(mean(sil_A[, 3]),
                            mean(sil_B[, 3]),
                            pam_C$silinfo$avg.width), 3)
) |> tabla("Ajuste de las tres variantes")
Ajuste de las tres variantes
Variante Observaciones Varianza explicada (%) Silueta media
A. k-medias sobre componentes 8.232 63,6 0,456
B. k-medias sobre variables originales 8.232 52,5 0,374
C. PAM sobre Gower (muestra) 2.500 NA 0,427

El parámetro nstart = 50permite al método k-medias converge a un óptimo local que depende de los centroides iniciales, elegidos al azar, de modo que ejecuciones distintas pueden producir particiones distintas. Repetir el procedimiento cincuenta veces con inicializaciones diferentes y conservar la mejor solución reduce sustancialmente ese riesgo.

6.5 Perfilamiento de los conglomerados

conglomerados <- scores_acp |>
  mutate(grupo = factor(km_A$cluster)) |>
  left_join(
    vivienda_final |> select(id, preciom, areaconst, precio_m2, log_area,
                             banios, habitaciones, parqueaderos, barrio,
                             segmento_edificio, longitud, latitud),
    by = "id")

conglomerados |>
  count(grupo) |>
  mutate(`% del total` = round(100 * n / sum(n), 1)) |>
  tabla("Tamaño de los conglomerados")
Tamaño de los conglomerados
grupo n % del total
1 1.075 13,1
2 4.480 54,4
3 2.677 32,5
orden_grupos <- conglomerados |>
  group_by(grupo) |>
  summarise(m = median(preciom, na.rm = TRUE), .groups = "drop") |>
  arrange(m) |>
  pull(grupo)

conglomerados <- conglomerados |>
  mutate(segmento = factor(grupo, levels = orden_grupos,
                           labels = c("Estándar", "Casas extensas", "Alto valor")))

conglomerados |>
  count(segmento) |>
  mutate(`% del total` = round(100 * n / sum(n), 1)) |>
  tabla("Segmentos con denominación estable")
Segmentos con denominación estable
segmento n % del total
Estándar 4.480 54,4
Casas extensas 1.075 13,1
Alto valor 2.677 32,5

La partición resultante es marcadamente desequilibrada: el conglomerado mayoritario concentra el 54.4% de la oferta, el intermedio el 32.5% y el minoritario el 13.1%. Este desequilibrio no invalida la solución —los segmentos de mercado no tienen por qué ser equipotentes.

conglomerados |>
  group_by(grupo) |>
  summarise(
    n                = n(),
    `Precio (mediana)`   = round(median(preciom, na.rm = TRUE), 1),
    `Área (mediana)`     = round(median(areaconst, na.rm = TRUE), 1),
    `Precio/m2`          = round(median(precio_m2, na.rm = TRUE), 2),
    `Estrato (mediana)`  = median(estrato, na.rm = TRUE),
    `Baños`              = round(mean(banios, na.rm = TRUE), 1),
    `Habitaciones`       = round(mean(habitaciones, na.rm = TRUE), 1),
    `Parqueaderos`       = round(mean(parqueaderos, na.rm = TRUE), 1),
    .groups = "drop"
  ) |> tabla("Perfil de cada conglomerado en las variables originales")
Perfil de cada conglomerado en las variables originales
grupo n Precio (mediana) Área (mediana) Precio/m2 Estrato (mediana) Baños Habitaciones Parqueaderos
1 1.075 395 265 1,48 4 4,3 6,2 1,2
2 4.480 230 84 2,67 4 2,2 2,9 0,9
3 2.677 650 220 3,17 6 4,2 3,8 2,6

Se reportan medianas y no medias para precio y área , según se documentó en el análisis exploratorio la mediana describe mejor el inmueble típico de cada grupo. El perfilamiento se realiza sobre las variables originales y no sobre las componentes porque los millones de pesos y los metros cuadrados son interpretables para la dirección de la empresa, mientras que una puntuación factorial no lo es.

comp <- function(var, titulo) {
  conglomerados |>
    filter(!is.na(.data[[var]])) |>
    count(grupo, categoria = as.character(.data[[var]])) |>
    group_by(grupo) |>
    mutate(prop = n / sum(n)) |>
    ungroup() |>
    ggplot(aes(grupo, prop, fill = categoria)) +
    geom_col() +
    scale_fill_viridis_d() +
    labs(x = "Conglomerado", y = "Proporción", fill = NULL, subtitle = titulo) +
    theme_minimal()
}

grid.arrange(comp("tipo", "Tipo de inmueble"),
             comp("estrato", "Estrato"),
             comp("zona", "Zona"), ncol = 1)
Composición de cada conglomerado por tipo de inmueble, estrato y zona.

Composición de cada conglomerado por tipo de inmueble, estrato y zona.

fviz_cluster(km_A, data = entrada_A,
             geom = "point", pointsize = 0.8, alpha = 0.25,
             ellipse.type = "norm", ellipse.level = 0.68,
             ggtheme = theme_minimal()) +
  labs(title = NULL,
       x = paste0("PC1 (", varianza$`% varianza`[1], "%)"),
       y = paste0("PC2 (", varianza$`% varianza`[2], "%)"))
Conglomerados representados sobre el primer plano factorial.

Conglomerados representados sobre el primer plano factorial.

6.5.1 Denominación de los segmentos

El perfilamiento permite identificar tres segmentos caracterizados:

6.5.1.1 Conglomerado 1

El conglomerado 1 Denominado casas extensas de bajo valor unitario, reune 1075 inmuebles (13.1% de la oferta) y esta compuesto en su casi totalidad por casas. Constituye el segmento de mayor superficie con una mediana de 265 m^2 y el mayor numero de baños y habitaciones. No obstante presenta el menor precio por metro cuadrado de los tres segmentos con un valor de **1.48* millones tambien la dotación de parqueaderos en relacion al numero de habitaciones y baños resulta baja. su distribucción a traves de los estratos tambienr esutla la mas diversa desde el estrato 3 al 6.

La combinación de superficie elevada, elevado número de habitaciones, escasa dotación de parqueadero y bajo valor unitario resulta característica de vivienda de construcción antigua situada en barrios consolidados, y no del segmento de lujo que sus dimensiones podrían sugerir en una lectura basada únicamente en el precio absoluto. Este hallazgo ilustra la utilidad de haber incorporado el precio por metro cuadrado al análisis: evaluado por precio total, el conglomerado 1 ocuparía una posición intermedia entre los otros dos, mientras que evaluado por valor unitario ocupa la última.

6.5.1.2 Conglomerado 2

El conglomerado 2 denominado vivienda estándar de mercado masivo, agrupa 4480 inmuebles equivalentes al 54.4% de la oferta y se reparte en una proporción aproximada de 80% apartamentos y 20% casas, presenta una distribución similar al conglomerado 1 en cuanto a estratos con la diferencia de contar con una menor participación del estrato 3 y una mayor participación del estrato 5. Presenta el precio por mediana mas bajo de los 3 grupos al igual que el menor área con 84 m^2. Es el segmento de menor superficie por habitación con 29 m^2 y el de mayor volumen, lo que lo identifica como el mercado de referencia para operaciones de rotación alta y alto volumen para la compañía inmobiliaria

6.5.1.3 Conglomerado 3

El conglomerado 3 denominado segmento de alto valor agryoa 2677 inmuebles siendo el 32.5% de la oferta y se reparte de forma equilibrada entre casas y apartamentos, este esta enfocado principalmente en los estratos 5 y 6. Presenta el mayor precio por mediana (650 millones) de los 3 grupos y el mayor precio por metro cuadrado con 3,17 millones por metro.Sus rasgos distintivos no son de tamaño sino de calidad: 2.6 parqueaderos por unidad, 57.9 m^2 por habitación y una proporción de 1.11 baños por habitación, la más alta de los tres segmentos y un indicador reconocido de nivel de acabados. Se trata del segmento donde el valor procede de la localización y de la calidad constructiva antes que de la superficie.

6.6 Distribución espacial de los conglomerados

conglomerados |>
  filter(!is.na(longitud), !is.na(latitud)) |>
  ggplot(aes(longitud, latitud, colour = grupo)) +
  geom_point(alpha = 0.4, size = 0.7) +
  coord_fixed() +
  scale_colour_viridis_d(name = "Conglomerado") +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Distribución geográfica de los conglomerados.

Distribución geográfica de los conglomerados.

El mapa interactivo permite examinar la composición de cada zona a nivel de inmueble individual y verificar que las concentraciones observadas corresponden a sectores urbanos reconocibles.

muestra_clus <- conglomerados |>
  filter(!is.na(longitud), !is.na(latitud)) |>
  slice_sample(n = min(3000, nrow(conglomerados)))

pal_clus <- colorFactor("viridis", domain = muestra_clus$grupo)

leaflet(muestra_clus) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud,
    radius = 3, stroke = FALSE, fillOpacity = 0.65,
    color = ~pal_clus(grupo),
    popup = ~paste0("<b>Conglomerado ", grupo, "</b><br>",
                    barrio, "<br>", tipo, " · Estrato ", estrato, "<br>",
                    preciom, " millones · ", areaconst, " m2")) |>
  addLegend("bottomright", pal = pal_clus, values = ~grupo,
            title = "Conglomerado", opacity = 0.8)

Mapa interactivo de los conglomerados.

conglomerados |>
  filter(!is.na(barrio)) |>
  count(grupo, barrio) |>
  group_by(grupo) |>
  slice_max(n, n = 5, with_ties = FALSE) |>
  summarise(`Barrios predominantes` = paste(barrio, collapse = ", "), .groups = "drop") |>
  tabla("Barrios con mayor presencia de cada conglomerado")
Barrios con mayor presencia de cada conglomerado
grupo Barrios predominantes
1 el caney, ciudad 2000, el limonar, acopi, la flora
2 valle del lili, la flora, el caney, la hacienda, el ingenio
3 ciudad jardín, pance, santa teresita, normandía, los cristales

Esta representación constituye la validación externa anunciada en el análisis exploratorio. Las coordenadas geográficas no participaron en la construcción de los conglomerados: la partición se obtuvo exclusivamente a partir de atributos físicos y económicos del inmueble.

6.7 Validación

6.7.1 Calidad interna: anchura de silueta

idx_sil <- sample(nrow(entrada_A), 2000)
sil <- silhouette(km_A$cluster[idx_sil], dist(entrada_A[idx_sil, ]))

fviz_silhouette(sil, print.summary = FALSE) +
  labs(title = NULL) +
  theme_minimal()
Anchura de silueta por observación y conglomerado.

Anchura de silueta por observación y conglomerado.

tibble(
  Conglomerado = levels(factor(sil[, 1])),
  `Silueta media` = round(tapply(sil[, 3], sil[, 1], mean), 3),
  `% con silueta negativa` = round(100 * tapply(sil[, 3] < 0, sil[, 1], mean), 1)
) |> tabla("Calidad de asignación por conglomerado")
Calidad de asignación por conglomerado
Conglomerado Silueta media % con silueta negativa
1 0,309 0,4
2 0,513 0,1
3 0,382 0,8

La anchura media de silueta alcanza 0.456 en el conjunto, valor que conforme a la convención habitual corresponde a una estructura débil pero razonable. El comportamiento difiere entre segmentos: el de alto valor obtiene 0.513, indicativo de asignación sólida, mientras que el de casas extensas se queda en 0.309, lo que revela fronteras difusas con los restantes. La proporción de observaciones con silueta negativa esto es, mejor asignadas a otro grupo— se mantiene por debajo del 1% en los tres casos, de modo que no existen asignaciones sistemáticamente erróneas.

6.7.2 Concordancia entre métodos

idx_ari <- sample(nrow(entrada_A), 2000)

# Comparación entre las variantes A y B sobre las mismas observaciones
ari_AB <- fpc::cluster.stats(
  d              = dist(entrada_A[idx_ari, ]),
  clustering     = km_A$cluster[idx_ari],
  alt.clustering = km_B$cluster[idx_ari]
)$corrected.rand

# Comparación entre la variante A y PAM-Gower sobre la muestra común
grupos_A_muestra <- km_A$cluster[if (exists("filas_C")) filas_C else idx_muestra]

stopifnot(length(grupos_A_muestra) == length(pam_C$clustering))

ari_AC <- fpc::cluster.stats(
  d              = d_gower,
  clustering     = pam_C$clustering,
  alt.clustering = grupos_A_muestra
)$corrected.rand

tibble(
  Comparación = c("A frente a B (componentes vs. variables originales)",
                  "A frente a C (euclidiana vs. Gower)"),
  `Índice de Rand ajustado` = round(c(ari_AB, ari_AC), 3)
) |> tabla("Concordancia entre las particiones obtenidas por métodos distintos")
Concordancia entre las particiones obtenidas por métodos distintos
Comparación Índice de Rand ajustado
A frente a B (componentes vs. variables originales) 0,980
A frente a C (euclidiana vs. Gower) 0,415

6.8 Síntesis del análisis de conglomerados

Resumen del análisis de conglomerados
Elemento Resultado
Entrada adoptada Puntuaciones factoriales del ACP (variante A)
Algoritmo k-medias con 50 inicializaciones aleatorias
Número de conglomerados 3
Criterio de selección Máximo de la anchura de silueta, contrastado con el estadístico de brecha, el dendrograma y la interpretabilidad comercial
Varianza explicada por la partición 63.6%
Silueta media global 0.456 — estructura débil pero razonable según la convención habitual
Silueta por segmento Alto valor 0.513 · Estándar 0.382 · Casas extensas 0.309
Observaciones mal asignadas Menos del 1% con silueta negativa en los tres segmentos
Concordancia con variables originales Rand ajustado de 0.980
Concordancia con Gower sobre variables mixtas Rand ajustado de 0.415, atribuible al peso de la variable tipo en la distancia de Gower
Validación externa Correspondencia geográfica de los segmentos con sectores urbanos reconocibles, pese a no emplear las coordenadas en su construcción
Supuesto más frágil k-medias presupone conglomerados de forma aproximadamente esférica y tamaño similar, condición que un mercado inmobiliario continuo no tiene por qué satisfacer

La segmentación obtenida resulta estable frente a la elección de la entrada, presenta una calidad interna razonable y encuentra correspondencia geográfica pese a haberse construido sin información espacial. Debe subrayarse, no obstante, que el estadístico de brecha no permitió rechazar la hipótesis de ausencia de estructura: los tres segmentos constituyen una partición operativa de un continuo, útil para orientar decisiones comerciales, y no grupos naturalmente separados en el mercado. Sus resultados se retoman en el apartado de recomendaciones estratégicas.

7 Análisis de Correspondencia

El análisis de correspondencias examina la estructura de asociación entre variables categóricas y la representa en un espacio de dimensión reducida, de forma análoga a como el análisis de componentes principales procede con variables continuas. Su objetivo en este estudio es identificar qué combinaciones de tipo de vivienda, zona, barrio y nivel de precio se presentan con mayor frecuencia de la esperable bajo independencia, revelando patrones de comportamiento de la oferta que las técnicas anteriores, restringidas a variables numéricas, no podían capturar.

La lógica del procedimiento consiste en descomponer el estadístico chi-cuadrado de una tabla de contingencia en dimensiones ortogonales ordenadas por la porción de asociación que explican. La distancia entre puntos del mapa resultante no es euclidiana sino ji-cuadrado, lo que implica que dos categorías aparecen próximas cuando sus perfiles de distribución son similares, con independencia de su frecuencia absoluta.

7.1 Tablas de contingencia y contraste chi-cuadrado

7.1.1 Asociación entre tipo de vivienda y zona

tab_tz <- table(Tipo = vivienda_final$tipo, Zona = vivienda_final$zona)

tab_tz |> as.data.frame.matrix() |>
  rownames_to_column("Tipo") |>
  tabla("Distribución conjunta de tipo de vivienda y zona")
Distribución conjunta de tipo de vivienda y zona
Tipo Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 24 1.188 1.024 61 2.777
Casa 97 699 158 283 1.921
round(100 * prop.table(tab_tz, margin = 2), 1) |>
  as.data.frame.matrix() |>
  rownames_to_column("Tipo") |>
  tabla("Perfil de cada zona: composición porcentual por tipo de vivienda")
Perfil de cada zona: composición porcentual por tipo de vivienda
Tipo Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 19,8 63 86,6 17,7 59,1
Casa 80,2 37 13,4 82,3 40,9

La composición por tipo difiere marcadamente entre zonas. La Zona Oeste concentra la mayor proporción de apartamentos (86.6%), seguida de la Zona Norte (63.0%) y la Zona Sur (59.1%). En el extremo opuesto, la Zona Oriente y la Zona Centro son predominantemente de casas (82.3% y 80.2% respectivamente).

chi_tz <- chisq.test(tab_tz)

tibble(
  Indicador = c("Chi-cuadrado", "Grados de libertad", "Valor p",
                "Frecuencia esperada mínima", "Celdas con esperada < 5",
                "V de Cramér"),
  Valor = c(round(chi_tz$statistic, 1), chi_tz$parameter,
            format.pval(chi_tz$p.value, eps = 1e-16),
            round(min(chi_tz$expected), 1), sum(chi_tz$expected < 5),
            round(sqrt(chi_tz$statistic / (sum(tab_tz) * (min(dim(tab_tz)) - 1))), 3))
) |> tabla("Contraste de independencia entre tipo de vivienda y zona")
Contraste de independencia entre tipo de vivienda y zona
Indicador Valor
Chi-cuadrado 696.2
Grados de libertad 4
Valor p < 1e-16
Frecuencia esperada mínima 46.4
Celdas con esperada < 5 0
V de Cramér 0.291

El contraste rechaza la independencia entre tipo de vivienda y zona (χ^2 = 696.2 con 4 grados de libertad, p < 10⁻¹⁶), y la verificación previa confirma la validez de la aproximación: la frecuencia esperada mínima asciende a 46.4 y ninguna celda queda por debajo del umbral de 5. La V de Cramér, sin embargo, alcanza únicamente 0.291, valor que conforme a la convención habitual corresponde a una asociación de intensidad débil a moderada. La distinción entre significación estadística y relevancia práctica resulta aquí especialmente pertinente: con 8232 observaciones el rechazo era esperable pues el tipo de vivienda solo explica una fracción modesta de la variabilidad entre zonas.

chi_tz$stdres |>
  as.data.frame() |>
  ggplot(aes(Zona, Tipo, fill = Freq)) +
  geom_tile(colour = "white") +
  geom_text(aes(label = round(Freq, 1)), size = 3.5) +
  scale_fill_gradient2(low = "steelblue", mid = "white", high = "firebrick",
                       midpoint = 0, name = "Residuo") +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Residuos estandarizados de la tabla tipo por zona. Los valores positivos indican sobrerrepresentación respecto de la independencia.

Residuos estandarizados de la tabla tipo por zona. Los valores positivos indican sobrerrepresentación respecto de la independencia.

El mapa de residuos localiza el origen de la asociación. Los valores positivos más elevados corresponden a la combinación de casa con las zonas Oriente y Centro, y de apartamento con la Zona Oeste; los negativos señalan las combinaciones inversas. La Zona Sur, pese a concentrar el 57.1% de la oferta, presenta residuos próximos a cero en ambos tipos, lo que indica que su composición reproduce la media general y que no aporta información diferencial a esta asociación.

7.1.2 Asociación entre zona y estrato

tab_ze <- table(Zona = vivienda_final$zona, Estrato = vivienda_final$estrato)
chi_ze <- chisq.test(tab_ze)

tab_ze |> as.data.frame.matrix() |>
  rownames_to_column("Zona") |>
  tabla("Distribución conjunta de zona y estrato")
Distribución conjunta de zona y estrato
Zona 3 4 5 6
Zona Centro 102 14 4 1
Zona Norte 566 393 758 170
Zona Oeste 51 82 282 767
Zona Oriente 333 8 2 1
Zona Sur 378 1.605 1.678 1.037
tibble(
  Indicador = c("Chi-cuadrado", "Grados de libertad", "Valor p",
                "Frecuencia esperada mínima", "Celdas con esperada < 5", "V de Cramér"),
  Valor = c(round(chi_ze$statistic, 1), chi_ze$parameter,
            format.pval(chi_ze$p.value, eps = 1e-16),
            round(min(chi_ze$expected), 1), sum(chi_ze$expected < 5),
            round(sqrt(chi_ze$statistic / (sum(tab_ze) * (min(dim(tab_ze)) - 1))), 3))
) |> tabla("Contraste de independencia entre zona y estrato")
Contraste de independencia entre zona y estrato
Indicador Valor
Chi-cuadrado 3807
Grados de libertad 12
Valor p < 1e-16
Frecuencia esperada mínima 21
Celdas con esperada < 5 0
V de Cramér 0.393

La distribución por estrato revela una estructura territorial nítida. La Zona Oriente concentra el 96.8% de su oferta en estrato 3 y la Zona Centro el 84.3%, configurando ambas un perfil socioeconómico homogéneo y bajo dentro del rango observado. En el extremo opuesto, la Zona Oeste sitúa el 64.9% de su oferta en estrato 6, lo que la identifica como la zona de mayor nivel. La Zona Norte se reparte principalmente entre los estratos 3 y 5, y la Zona Sur —que reúne el 57.1% del total distribuye su oferta entre los estratos 4, 5 y 6 sin predominio claro de ninguno. El contraste rechaza la independencia (χ^2 = 3807 con 12 grados de libertad) y la V de Cramér alcanza 0.393, correspondiente a una asociación moderada y sensiblemente más intensa que la observada entre tipo y zona.

Debe declararse una limitación del alcance del estudio: el conjunto no registra inmuebles de estratos 1 y 2, de modo que las conclusiones se refieren exclusivamente al segmento de mercado comprendido entre los estratos 3 y 6 y no son extensibles a la vivienda de interés social.

7.1.3 Comparación de la intensidad de las asociaciones

v_cramer <- function(x, y) {
  t <- table(x, y); ch <- suppressWarnings(chisq.test(t))
  tibble(
    `Frecuencia esperada mínima` = round(min(ch$expected), 2),
    `Chi-cuadrado`               = round(as.numeric(ch$statistic), 1),
    `Valor p`                    = format.pval(ch$p.value, eps = 1e-16),
    `V de Cramér`                = round(sqrt(as.numeric(ch$statistic) /
                                    (sum(t) * (min(dim(t)) - 1))), 3))
}

bind_rows(
  v_cramer(vivienda_final$tipo,        vivienda_final$zona)        |> mutate(Par = "tipo × zona"),
  v_cramer(vivienda_final$tipo,        vivienda_final$estrato)     |> mutate(Par = "tipo × estrato"),
  v_cramer(vivienda_final$zona,        vivienda_final$estrato)     |> mutate(Par = "zona × estrato"),
  v_cramer(vivienda_final$zona,        vivienda_final$precio_cat)  |> mutate(Par = "zona × precio"),
  v_cramer(vivienda_final$estrato,     vivienda_final$precio_cat)  |> mutate(Par = "estrato × precio"),
  v_cramer(vivienda_final$tipo,        vivienda_final$area_cat)    |> mutate(Par = "tipo × área"),
  v_cramer(vivienda_final$barrio_agr,  vivienda_final$precio_cat)  |> mutate(Par = "barrio × precio")
) |>
  select(Par, everything()) |>
  arrange(desc(`V de Cramér`)) |>
  tabla("Intensidad de la asociación entre pares de variables categóricas")
Intensidad de la asociación entre pares de variables categóricas
Par Frecuencia esperada mínima Chi-cuadrado Valor p V de Cramér
tipo × área 760,73 3.049,6 < 1e-16 0,609
estrato × precio 347,95 5.144,3 < 1e-16 0,456
barrio × precio 12,17 4.590,2 < 1e-16 0,431
zona × estrato 21,02 3.807,0 < 1e-16 0,393
tipo × zona 46,42 696,2 < 1e-16 0,291
zona × precio 29,44 1.138,6 < 1e-16 0,215
tipo × estrato 548,58 215,9 < 1e-16 0,162

El ordenamiento por intensidad permite extraer tres lecturas. En primer lugar, la asociación más fuerte corresponde a tipo con área (V = 0.609), resultado esperable y de escaso valor informativo, pues expresa que las casas son sistemáticamente más amplias que los apartamentos. En segundo lugar, y de forma menos evidente, la asociación entre tipo y estrato es la más débil del conjunto (V = 0.162): casas y apartamentos coexisten en todos los niveles socioeconómicos, de modo que el tipo de producto no determina el segmento al que pertenece el inmueble. En tercer lugar, el barrio discrimina el nivel de precio con una intensidad que duplica la de la zona (V = 0.431 frente a 0.215), lo que indica que el análisis a escala zonal pierde una parte sustancial de la información de localización y que el barrio constituye la unidad territorial pertinente para la valoración.

7.2 Análisis de correspondencias simples

7.2.1 Selección de la tabla

El análisis de correspondencias simples opera sobre una tabla de contingencia de dos entradas y extrae un número de dimensiones igual a \(\min(f-1,\, c-1)\), siendo \(f\) y \(c\) el número de filas y columnas. Ello impone una restricción que conviene enunciar: la tabla de tipo por zona, al disponer de únicamente dos filas, admite una sola dimensión y su representación gráfica se reduce a un eje, de modo que no procede aplicarle la técnica. Se selecciona en su lugar la tabla de barrio por estrato, que combina un número elevado de categorías en ambas entradas con una asociación intensa, y permite responder directamente a la pregunta de cómo se distribuye la oferta por sector de la ciudad.

tab_be <- table(vivienda_final$barrio_agr, vivienda_final$estrato)
tab_be <- tab_be[rowSums(tab_be) > 0, colSums(tab_be) > 0]

acs_be <- FactoMineR::CA(tab_be, graph = FALSE)

tibble(
  Dimensión      = paste0("Dim ", seq_len(nrow(acs_be$eig))),
  `Valor propio` = round(acs_be$eig[, 1], 4),
  `% inercia`    = round(acs_be$eig[, 2], 2),
  `% acumulado`  = round(acs_be$eig[, 3], 2)
) |> tabla("Descomposición de la inercia en el análisis de correspondencias simples")
Descomposición de la inercia en el análisis de correspondencias simples
Dimensión Valor propio % inercia % acumulado
Dim 1 0,609 55,17 55,17
Dim 2 0,322 29,11 84,28
Dim 3 0,174 15,72 100,00
tibble(
  Indicador = c("Inercia total", "Chi-cuadrado equivalente", "Dimensiones extraídas"),
  Valor = c(round(sum(acs_be$eig[, 1]), 4),
            round(sum(acs_be$eig[, 1]) * sum(tab_be), 1),
            nrow(acs_be$eig))
) |> tabla("Inercia total de la tabla analizada")
Inercia total de la tabla analizada
Indicador Valor
Inercia total 1,104
Chi-cuadrado equivalente 9.091,000
Dimensiones extraídas 3,000

La inercia total asciende a 1.104, valor considerablemente elevado en tablas de contingencia habituales suele situarse entre 0.1 y 0.5 que confirma una asociación intensa entre barrio y estrato. Las tres dimensiones extraídas explican respectivamente el 55.17%, el 29.11% y el 15.72% de esa inercia, de modo que el primer plano factorial retiene el 84.28% y ofrece una representación fiel de la estructura.

fviz_ca_biplot(acs_be, repel = TRUE, map = "symmetric",
               col.row = "grey45", col.col = "firebrick",
               labelsize = 3) +
  labs(title = NULL) +
  theme_minimal()
Mapa perceptual del análisis de correspondencias entre barrio y estrato.

Mapa perceptual del análisis de correspondencias entre barrio y estrato.

El mapa dispone los barrios y los estratos sobre el primer plano factorial. La lectura debe atender a que las distancias entre puntos de una misma nube barrio con barrio, o estrato con estrato son directamente interpretables, mientras que la posición relativa entre un barrio y un estrato solo admite lectura direccional: ambos se encuentran asociados cuando se sitúan en la misma dirección desde el origen y a distancia apreciable de él. Los barrios próximos al centro carecen de perfil distintivo y reproducen la composición media de la ciudad.

grid.arrange(
  fviz_contrib(acs_be, choice = "row", axes = 1, top = 15) + labs(title = "Dimensión 1"),
  fviz_contrib(acs_be, choice = "row", axes = 2, top = 15) + labs(title = "Dimensión 2"),
  ncol = 2)

acs_be$col$cos2 |> round(3) |> as.data.frame() |>
  rownames_to_column("Estrato") |>
  tabla("Calidad de representación de los estratos en el mapa (cos2)")
Calidad de representación de los estratos en el mapa (cos2)
Estrato Dim 1 Dim 2 Dim 3
3 0,123 0,864 0,014
4 0,447 0,079 0,474
5 0,186 0,385 0,429
6 0,994 0,000 0,005

La calidad de representación difiere apreciablemente entre estratos. El estrato 6 queda prácticamente explicado por la primera dimensión (cos² = 0.994), que se configura por tanto como un eje de nivel socioeconómico alto frente al resto. El estrato 3 se asocia a la segunda dimensión (cos² = 0.864).

7.3 Análisis de correspondencias múltiples

7.3.1 Selección de variables activas e ilustrativas

Papel de cada variable en el análisis de correspondencias múltiples
Variable Papel Categorías Justificación
tipo Activa 2 Distinción estructural del producto inmobiliario
estrato_cat Activa 6 Principal determinante socioeconómico
precio_cat Activa 4 Variable objetivo del negocio, discretizada en cuartiles
area_cat Activa 4 Dimensión física principal, discretizada en cuartiles
tiene_parqueadero Activa 2 Atributo de dotación con valor comercial
piso_cat Activa 3 Recoge el quiebre detectado entre los pisos 5 y 6
zona Activa 5 Localización a escala agregada
barrio_agr Ilustrativa 36 Su elevado número de categorías dominaría la inercia; se proyecta sin intervenir en la construcción de los ejes
segmento Ilustrativa 3 Resultado del análisis de conglomerados; se proyecta para verificar su correspondencia con la estructura categórica
datos_acm <- vivienda_final |>
  left_join(conglomerados |> select(id, segmento), by = "id") |>
  transmute(
    tipo              = factor(tipo),
    estrato_cat       = factor(estrato_cat, ordered = FALSE),
    precio_cat        = factor(precio_cat, ordered = FALSE),
    area_cat          = factor(area_cat, ordered = FALSE),
    tiene_parqueadero = factor(tiene_parqueadero),
    # La ausencia de piso_cat en casas es estructural: se codifica como categoría propia
    piso_cat          = factor(if_else(is.na(as.character(piso_cat)),
                                       "No aplica", as.character(piso_cat)),
                               levels = c("1-4", "5", "6+", "No aplica")),
    zona              = factor(zona),
    barrio_agr        = factor(barrio_agr),
    segmento          = factor(segmento)
  ) |>
  drop_na()

tibble(
  Observaciones = nrow(datos_acm),
  `Variables activas` = 7,
  `Variables ilustrativas` = 2,
  `Modalidades activas` = sum(sapply(datos_acm[, 1:7], nlevels))
) |> tabla("Conjunto efectivo del análisis de correspondencias múltiples")
Conjunto efectivo del análisis de correspondencias múltiples
Observaciones Variables activas Variables ilustrativas Modalidades activas
8.232 7 2 25

El conjunto efectivo conserva las 8232 observaciones, sin pérdida alguna por ausencia de datos, y comprende 25 modalidades activas distribuidas entre las siete variables seleccionadas. La codificación de la ausencia de piso_cat como modalidad propia resulta determinante para este resultado: eliminarla habría supuesto descartar la totalidad de la oferta de casas.

acm <- MCA(datos_acm,
           quali.sup = c(8, 9),   # barrio_agr y segmento
           graph = FALSE)

tibble(
  Dimensión      = paste0("Dim ", 1:min(10, nrow(acm$eig))),
  `Valor propio` = round(acm$eig[1:min(10, nrow(acm$eig)), 1], 4),
  `% inercia`    = round(acm$eig[1:min(10, nrow(acm$eig)), 2], 2),
  `% acumulado`  = round(acm$eig[1:min(10, nrow(acm$eig)), 3], 2)
) |> tabla("Descomposición de la inercia en el análisis de correspondencias múltiples")
Descomposición de la inercia en el análisis de correspondencias múltiples
Dimensión Valor propio % inercia % acumulado
Dim 1 0,408 15,86 15,86
Dim 2 0,334 12,98 28,84
Dim 3 0,249 9,69 38,52
Dim 4 0,197 7,67 46,19
Dim 5 0,161 6,25 52,44

Los porcentajes de inercia obtenidos resultan aparentemente reducidos: la primera dimensión explica el 15.86% y la segunda el 12.98%, acumulando el 28.84% en el primer plano.

7.3.2 Corrección de los valores propios

Q      <- 7                       # número de variables activas
lambda <- acm$eig[, 1]
lambda_c <- ifelse(lambda > 1/Q, (Q/(Q-1))^2 * (lambda - 1/Q)^2, 0)

tibble(
  Dimensión           = paste0("Dim ", seq_along(lambda))[lambda_c > 0],
  `% bruto`           = round(acm$eig[lambda_c > 0, 2], 2),
  `% corregido`       = round(100 * lambda_c[lambda_c > 0] / sum(lambda_c), 2),
  `% corregido acum.` = round(cumsum(100 * lambda_c[lambda_c > 0] / sum(lambda_c)), 2)
) |> tabla("Valores propios brutos y corregidos por el procedimiento de Benzécri")
Valores propios brutos y corregidos por el procedimiento de Benzécri
Dimensión % bruto % corregido % corregido acum.
Dim 1 15,86 57,92 57,92
Dim 2 12,98 30,07 87,99
Dim 3 9,69 9,31 97,30
Dim 4 7,67 2,43 99,73
Dim 5 6,25 0,27 100,00

La corrección modifica sustancialmente la lectura: la primera dimensión pasa del 15.86% al 57.92% de la inercia y la segunda del 12.98% al 30.07%, de modo que el primer plano factorial retiene el 87.99% en lugar del 28.84% que sugerían los valores brutos, un factor de 3.1. la corrección descarta las dimensiones cuyo valor propio no supera 1/Q y reescala las restantes. Se reportan ambas versiones porque el procedimiento de Benzécri tiende a sobrestimar mientras que la alternativa de Greenacre resulta más conservadora, de forma que el intervalo entre ambos porcentajes acota razonablemente la información retenida.

fviz_mca_var(acm, choice = "var.cat", repel = TRUE,
             col.var = "contrib", labelsize = 3,
             gradient.cols = c("grey70", "steelblue", "darkblue")) +
  labs(title = NULL) +
  theme_minimal()
Mapa perceptual de las modalidades activas sobre el primer plano factorial.

Mapa perceptual de las modalidades activas sobre el primer plano factorial.

El mapa dispone las 25 modalidades activas sobre el primer plano. El gradiente de color señala la contribución de cada modalidad a la construcción de los ejes, de modo que las representadas en tonos oscuros son las que determinan la estructura y las de tono claro ocupan posiciones que el análisis no sostiene con firmeza.

grid.arrange(
  fviz_contrib(acm, choice = "var", axes = 1, top = 15) + labs(title = "Dimensión 1"),
  fviz_contrib(acm, choice = "var", axes = 2, top = 15) + labs(title = "Dimensión 2"),
  ncol = 2)
Contribución de las modalidades a las dos primeras dimensiones.

Contribución de las modalidades a las dos primeras dimensiones.

Las modalidades que superan la línea de referencia en la primera dimensión corresponden a los extremos de precio y área,Pequeño y Económico frente a Alto y Muy amplio junto con el estrato 6. En la segunda dimensión destacan el estrato 3, la ausencia de parqueadero, la modalidad Casa y la categoría No aplica de piso_cat, esta última asociada por construcción a las casas.

acm$var$coord[, 1:2] |>
  as.data.frame() |>
  rownames_to_column("Modalidad") |>
  mutate(across(where(is.numeric), ~ round(.x, 3)),
         cos2_Dim1 = round(acm$var$cos2[, 1], 3),
         cos2_Dim2 = round(acm$var$cos2[, 2], 3)) |>
  arrange(desc(abs(`Dim 1`))) |>
  tabla("Coordenadas y calidad de representación de las modalidades activas")
Coordenadas y calidad de representación de las modalidades activas
Modalidad Dim 1 Dim 2 cos2_Dim1 cos2_Dim2
Pequeño 1,274 0,107 0,570 0,004
Económico 1,261 0,477 0,567 0,081
Alto -1,165 0,001 0,449 0,000
Muy amplio -1,067 0,591 0,378 0,116
estrato_cat_6 -1,006 -0,368 0,320 0,043
piso_cat_5 0,883 -0,341 0,057 0,009
estrato_cat_3 0,771 1,445 0,125 0,439
No 0,763 1,098 0,135 0,281
Zona Oeste -0,737 -0,740 0,091 0,092
Casa -0,669 0,845 0,278 0,445
piso_cat_1-4 0,643 -0,621 0,137 0,128
estrato_cat_4 0,604 -0,064 0,125 0,001
Amplio -0,517 -0,011 0,089 0,000
Medio-alto -0,467 -0,107 0,071 0,004
Zona Oriente 0,464 2,305 0,009 0,232
piso_cat_No aplica -0,422 0,575 0,218 0,405
Apartamento 0,416 -0,526 0,278 0,445
Zona Norte 0,375 0,344 0,042 0,035
Medio-bajo 0,299 -0,408 0,029 0,054
Mediano 0,265 -0,716 0,022 0,163
Si -0,177 -0,255 0,135 0,281
estrato_cat_5 -0,141 -0,442 0,010 0,097
piso_cat_6+ 0,086 -1,050 0,001 0,167
Zona Centro 0,071 1,965 0,000 0,058
Zona Sur -0,001 -0,171 0,000 0,039

La tabla ordena las modalidades por su coordenada sobre la primera dimensión y permite verificar simultáneamente su calidad de representación. Las modalidades mejor representadas son Pequeño (0.574), Económico (0.648) y Alto (0.449). Debe advertirse en cambio que las cinco modalidades de zona presentan una calidad de representación muy reducida Zona Sur 0.039, Zona Centro 0.058, Zona Norte 0.077, Zona Oriente 0.241 y Zona Oeste 0.183, de modo que su posición en el mapa no admite interpretación pese a que algunas aparecen en posiciones aparentemente extremas.

7.3.3 Proyección de las variables ilustrativas

fviz_ellipses(acm, habillage = "segmento",
              geom = "point", pointsize = 0.6, alpha.ind = 0.2) +
  labs(title = NULL) +
  theme_minimal()
Proyección de los segmentos obtenidos en el análisis de conglomerados sobre el plano de correspondencias.

Proyección de los segmentos obtenidos en el análisis de conglomerados sobre el plano de correspondencias.

La proyección sitúa los barrios en el espacio construido por las variables activas sin que hayan intervenido en su definición. Los barrios de perfil alto Ciudad Jardín (−0.966), Normandía (−0.867), Los Cristales (−0.712), El Peñón (−0.636) y Juanambú (−0.679) se agrupan en el semiplano negativo de la primera dimensión, mientras que Brisas de los Álamos (1.403), Meléndez (1.086) y El Caney (0.560) ocupan el positivo. La correspondencia entre esta ordenación y el conocimiento del mercado local respalda la interpretación.

acm$quali.sup$coord |>
  as.data.frame() |>
  rownames_to_column('Modalidad') |>
  select(1:3) |>
  mutate(Origen = if_else(Modalidad %in% levels(datos_acm$segmento), 'Segmento', 'Barrio'),
         across(where(is.numeric), ~ round(.x, 3))) |>
  arrange(Origen, desc(abs(`Dim 1`))) |>
  group_by(Origen) |> slice_head(n = 12) |> ungroup() |>
  tabla('Coordenadas de las modalidades ilustrativas')
Coordenadas de las modalidades ilustrativas
Modalidad Dim 1 Dim 2 Origen
torres de comfandi 1,634 0,895 Barrio
brisas de los 1,403 1,134 Barrio
parcelaciones pance -1,271 0,191 Barrio
melendez 1,086 0,482 Barrio
pance -1,039 -0,130 Barrio
santa teresita -1,023 -0,795 Barrio
villa del prado 0,975 1,561 Barrio
ciudad jardín -0,966 -0,113 Barrio
normandía -0,867 -0,935 Barrio
cristales -0,724 -0,866 Barrio
los cristales -0,712 -0,991 Barrio
valle del lili 0,701 -0,498 Barrio
Alto valor -0,992 -0,198 Segmento
Estándar 0,722 -0,146 Segmento
Casas extensas -0,540 1,100 Segmento
set.seed(42)
tibble(
  Dim1 = acm$ind$coord[, 1],
  Dim2 = acm$ind$coord[, 2],
  Segmento = datos_acm$segmento
) |>
  slice_sample(n = min(3000, nrow(datos_acm))) |>
  ggplot(aes(Dim1, Dim2, colour = Segmento)) +
  geom_point(alpha = 0.3, size = 0.8) +
  stat_ellipse(level = 0.68, linewidth = 0.8) +
  scale_colour_viridis_d() +
  labs(x = paste0("Dim 1 (", round(acm$eig[1, 2], 1), "%)"),
       y = paste0("Dim 2 (", round(acm$eig[2, 2], 1), "%)")) +
  theme_minimal()
Individuos proyectados sobre el primer plano factorial, diferenciados por segmento.

Individuos proyectados sobre el primer plano factorial, diferenciados por segmento.

La representación de los individuos permite apreciar el grado de solapamiento entre segmentos. Las elipses recogen el 68% de los casos de cada grupo, de modo que su separación indica en qué medida la estructura de asociación entre variables categóricas reproduce la segmentación obtenida a partir de variables continuas.

7.4 Mapa perceptual e interpretación

7.4.1 Denominación de las dimensiones

La primera dimensión, que retiene el 57.92% de la inercia corregida, se interpreta como un eje de NIVEL DE MERCADO. En su extremo positivo se sitúan las modalidades Pequeño (1.274) y Económico (1.261), y en el negativo Alto (−1.165), Muy amplio (−1.067) y estrato 6 (−1.006). Las cuatro modalidades de mayor contribución corresponden por tanto a los extremos de precio y de área, que se ordenan de forma concordante: los inmuebles económicos son también los de menor superficie y los de precio alto los de mayor. El eje reproduce así la gradación del mercado desde el producto más asequible hasta el de mayor valor, y coincide en su significado con la primera componente del análisis de componentes principales.

La segunda dimensión, con el 30.07% de la inercia corregida, se interpreta como un eje de TIPO DE PRODUCTO. Opone en su extremo positivo las modalidades Casa (0.845), estrato 3 (1.445), ausencia de parqueadero (1.098) y la categoría No aplica de piso_cat (0.575) esta última vinculada por construcción a las casas frente a Apartamento (−0.526), piso 6 o superior (−1.050) y área mediana (−0.716) en el negativo. El eje distingue por tanto la vivienda unifamiliar sin dotación de parqueadero de la vivienda en altura, y su significado es coherente con el segundo eje identificado en el análisis de conglomerados.

7.4.2 Patrones de comportamiento de la oferta

El mapa revela cuatro perfiles recurrentes de oferta. El primero combina precio económico, área pequeña, estrato 3 o 4 y ausencia de parqueadero, y se corresponde con la vivienda de menor gama del rango observado. El segundo asocia precio alto, área muy amplia, estrato 6 y disponibilidad de parqueadero, configurando el segmento superior. El tercero vincula la modalidad Casa con la ausencia de piso consecuencia de la codificación y con estratos intermedios. El cuarto asocia Apartamento con los pisos 6 o superiores y áreas medianas, correspondiente a la vivienda en torre con ascensor identificada en el análisis de la variable piso.

Entre las combinaciones infrecuentes destaca la oposición entre área pequeña y estrato 6, y entre casa y precio alto, ambas situadas en cuadrantes opuestos del plano.

7.4.3 Coherencia con el análisis de conglomerados

7.5 Síntesis del análisis de correspondencias

Resumen del análisis de correspondencias
Elemento Resultado
Asociación más intensa detectada tipo × área V de Cramér = 0.609
Tabla analizada mediante correspondencias simples barrio × estrato
Variables activas del análisis múltiple tipo, estrato, precio, área, parqueadero, piso, zona
Variables ilustrativas barrio (36 modalidades) y segmento del análisis de conglomerados
Inercia retenida en el primer plano (bruta) 28.84%
Inercia retenida en el primer plano (corregida) 87.99%⟩
Verificación de frecuencias esperadas Ninguna celda por debajo de 5; la frecuencia esperada mínima es de 12.17, correspondiente a la tabla barrio × precio⟩
Coherencia con el análisis de conglomerados Coherente: los tres segmentos se proyectan en regiones diferenciadas del primer plano factorial
Supuesto más frágil La discretización en cuartiles de precio y área impone puntos de corte que no corresponden a discontinuidades reales del mercado y condiciona las asociaciones detectadas

8 Conclusiones

8.1 Sobre la calidad y la estructura del conjunto de datos

El conjunto analizado presenta una calidad elevada en cuanto a validez de dominio: únicamente 87 registros, equivalentes al 1.04% del total, incumplen alguna regla de consistencia y fueron eliminados, quedando el análisis definitivo sobre 8232 observaciones. Los problemas relevantes no residieron por tanto en la presencia de valores imposibles sino en dos cuestiones de naturaleza distinta.

La primera fue la heterogeneidad semántica de la variable piso, que registra dos constructos diferentes bajo una misma columna: la posición dentro de la torre en los apartamentos y el número de plantas en las casas. Cualquier estadístico calculado sin separar por tipo de inmueble carecía de interpretación, de modo que la variable se dividió en dos con significado homogéneo.

La segunda fue el mecanismo de los datos faltantes. Se refutó la hipótesis de aleatoriedad completa mediante dos evidencias independientes: la dependencia entre los indicadores de ausencia de piso y parqueaderos (\(\chi^2_{(1)} = 171.4\), razón de momios de 2.09), que no requiere supuestos distribucionales, y la prueba de Little. Se estableció además que la ausencia en parqueaderos no constituía un fallo de captura sino la codificación implícita del valor cero, conclusión respaldada por tres evidencias convergentes y validada mediante análisis de sensibilidad.

8.2 Sobre la dimensionalidad del mercado

El análisis de componentes principales redujo seis variables a dos dimensiones que reproducen el 82.29% de la variabilidad original, decisión respaldada de forma unánime por los criterios de Kaiser, de varianza acumulada y de análisis paralelo de Horn.

La primera componente, con el 61.14% de la varianza, agrupa con cargas del mismo signo el precio, el área, el número de baños y el de parqueaderos, y se interpreta como una dimensión de tamaño y capacidad. La segunda, con el 21.16%, opone el número de habitaciones al estrato y configura una dimensión de nivel socioeconómico. La solución demostró ser robusta tanto ante el tratamiento del estrato como variable ordinal contrastado mediante correlaciones poliseriales como ante la exclusión de los registros marcados como atípicos, con congruencias de cargas de 0.953 y 0.993.

La conclusión sustantiva es que el mercado inmobiliario urbano se estructura en torno a dos ejes y no a uno solo: el tamaño del inmueble y el nivel de su localización son dimensiones separables, de modo que un inmueble puede ser grande sin ser caro por unidad de superficie, y viceversa.

8.3 Sobre la segmentación de la oferta

El análisis de conglomerados identificó tres segmentos cuya composición se resume a continuación.

Segmento Volumen Precio mediano Área mediana Precio por m² Estrato
Vivienda estándar 54.4% 230 M 84 m² 2.67 M 4
Casas extensas de bajo valor unitario 13.1% 395 M 265 m² 1.48 M 4
Alto valor 32.5% 650 M 220 m² 3.17 M 6

El hallazgo de mayor relevancia comercial procede del segundo segmento. Sus inmuebles presentan una superficie 3.15 veces mayor que los del segmento estándar y, sin embargo, un precio por metro cuadrado un 44.6% inferior. La combinación de superficie elevada, número alto de habitaciones, escasa dotación de parqueadero —0.45 plazas por cada 100 m² frente a 1.07 y 1.18 en los otros dos segmentos y bajo valor unitario corresponde a vivienda de construcción antigua en barrios consolidados, y no al segmento de lujo que sus dimensiones sugerirían si se atendiera únicamente al precio absoluto.

Debe precisarse el alcance de esta segmentación. El estadístico de brecha no permitió rechazar la hipótesis de ausencia de estructura, lo que indica que los inmuebles no forman agrupaciones naturalmente separadas sino que se distribuyen como un continuo. Los tres segmentos constituyen por tanto una partición operativa de ese continuo, útil para orientar decisiones comerciales, y no el descubrimiento de poblaciones preexistentes.

8.4 Sobre la estructura territorial y de producto

El análisis de correspondencias aportó tres resultados que las técnicas anteriores no podían capturar.

En primer lugar, el barrio discrimina el nivel de precio con el doble de intensidad que la zona (V de Cramér de 0.431 frente a 0.215). La localización opera en este mercado a escala de barrio, de modo que el análisis zonal pierde una parte sustancial de la información relevante para la valoración.

En segundo lugar, el tipo de vivienda es prácticamente independiente del estrato (V de Cramér de 0.162, la asociación más débil de las examinadas). Casas y apartamentos coexisten en todos los niveles socioeconómicos del rango observado, por lo que el tipo de producto no determina el segmento de mercado.

En tercer lugar, la estructura territorial presenta una polarización nítida: la Zona Oeste concentra el 64.9% de su oferta en estrato 6 y el 86.6% en apartamentos, mientras que las zonas Oriente y Centro son casi exclusivamente de estrato 3 y de casas. La Zona Sur, que reúne el 57.1% de la oferta, se proyecta prácticamente sobre el origen del plano factorial: carece de perfil distintivo y define la media del mercado.

8.5 Sobre la convergencia entre técnicas

Los tres procedimientos aplicados identificaron la misma estructura por vías metodológicamente independientes. Las dos dimensiones del análisis de componentes principales —tamaño y nivel— reaparecen como los dos ejes del análisis de correspondencias múltiples, pese a que el primero opera sobre variables continuas y el segundo exclusivamente sobre categóricas. La segmentación obtenida mediante k-medias resultó además estable frente a la elección de la entrada, con un índice de Rand ajustado de 0.980 entre la solución construida sobre las componentes y la construida sobre las variables originales.

La validación más sólida es de naturaleza externa. Los conglomerados se construyeron sin emplear las coordenadas geográficas, y sin embargo se proyectan sobre el territorio formando concentraciones reconocibles: el segmento de alto valor en Ciudad Jardín, Pance y Los Cristales; el de casas extensas en El Caney, Ciudad 2000 y El Limonar. Que barrios identificables por su perfil socioeconómico aparezcan asociados al segmento que les corresponde, sin que la localización interviniera en el procedimiento, respalda la validez de la segmentación mediante información ajena a su construcción.


9 Recomendaciones estratégicas

Las recomendaciones que siguen se derivan de los resultados del análisis y se formulan como líneas de actuación que la empresa debería contrastar con información de la que este estudio no dispone, en particular precios de transacción y costos de intervención.

9.1 Priorizar el segmento de casas extensas como oportunidad de reposicionamiento

Es la recomendación de mayor recorrido. El segmento reúne 1075 inmuebles con una superficie mediana de 265 m² y un valor unitario de 1.48 millones por metro cuadrado, un 44.6% inferior al del segmento estándar y menos de la mitad del segmento de alto valor. Esa brecha entre magnitud física y valor de mercado define una oportunidad de arbitraje.

Las líneas de actuación posibles son la adquisición para remodelación y reposicionamiento, la subdivisión en unidades menores para rentas cortas teniend en cuenta que el segmento presenta 6.2 habitaciones de media, muy por encima de la demanda residencial actual

9.2 Reemplazar la zona por el barrio como unidad de valoración

El análisis de correspondencias establece que el barrio discrimina el precio con el doble de intensidad que la zona. Los procesos internos de tasación, definición de precios de referencia y segmentación comercial que operen a escala zonal están descartando información sistemáticamente relevante.

Se recomienda reconstruir los modelos de valoración sobre el barrio como unidad territorial, agrupando únicamente aquellos con volumen insuficiente. Conviene señalar que 431 barrios distintos aparecen en el conjunto y que la mediana es de 3 inmuebles por barrio, de modo que la viabilidad de esta recomendación depende de acumular histórico suficiente en cada uno.

9.3 Explotar el segmento estándar mediante rotación

Con el 54.4% de la oferta, precio mediano de 230 millones y la menor superficie por habitación, este segmento constituye el mercado de referencia para operaciones de alto volumen y ciclo corto. Su elevada homogeneidad interna es el segmento con mayor volumen y perfil más definido permite estandarizar procesos de captación, tasación y comercialización, reduciendo el costo unitario por operación.

9.4 Incorporar la dotación de ascensor a los criterios de valoración

El análisis de la variable piso identificó una discontinuidad entre los pisos 5 y 6 asociada a una diferencia de precio del 44%, coherente con la exigencia normativa de ascensor a partir de cierta altura. El 70.6% de la oferta de apartamentos se concentra en edificios de hasta cinco pisos.

Se recomienda incorporar de forma explícita la existencia de ascensor a las fichas de producto y a los modelos de precio, y prestar atención específica a los inmuebles situados en el quinto piso de edificaciones sin ascensor, que presentan el descuento más acusado del conjunto y podrían constituir oportunidades de adquisición.

9.5 Cuantificar la dotación de parqueadero en la propuesta de valor

El análisis de sensibilidad estimó que cada plaza de parqueadero adicional se asocia a un incremento de precio de entre el 8.7% y el 9.2%, magnitud estable frente a los escenarios contemplados. Se recomienda que la disponibilidad y el número de plazas figuren de forma destacada en la comunicación comercial, particularmente en los segmentos estándar y de alto valor, donde la dotación es un elemento diferenciador.

9.6 Corregir el proceso de captura de información

Se recomienda incorporar el valor cero como opción explícita, desdoblar el campo de piso en dos según el tipo, y establecer campos obligatorios en el formulario de captación. Todas estas medidas incrementan sustancialmente la utilidad analítica del registro.


10 Limitaciones y supuestos frágiles

10.1 Cobertura del conjunto

El conjunto no registra inmuebles de estratos 1 y 2. Todas las conclusiones se refieren en consecuencia al mercado comprendido entre los estratos 3 y 6, y no son extensibles a la vivienda de interés social ni al segmento de menores ingresos.

10.2 Ausencia de dimensión temporal

El conjunto constituye un corte transversal sin marca de tiempo, de modo que no permite analizar tendencias, estacionalidad ni tiempo de permanencia en el mercado. Las recomendaciones relativas a rotación se sostienen en el volumen observado y no en velocidad de venta medida.

10.3 Naturaleza continua de la segmentación

El estadístico de brecha no permitió rechazar la ausencia de estructura de conglomerados. La partición en tres segmentos es operativa y no descriptiva de poblaciones separadas, por lo que las fronteras entre segmentos son graduales y un inmueble situado cerca del límite podría clasificarse en cualquiera de los dos grupos contiguos.

11 Reproducibilidad

sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
## [5] LC_TIME=Spanish_Colombia.utf8    
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] FactoMineR_2.16      fpc_2.2-14           cluster_2.1.8.3     
##  [4] factoextra_2.2.0     corrplot_0.95        kableExtra_1.4.0    
##  [7] leaflet_2.2.3        psych_2.6.1          robustbase_0.99-7   
## [10] mice_3.19.0          naniar_1.1.0         paqueteMODELOS_0.1.0
## [13] summarytools_1.1.5   knitr_1.51           gridExtra_2.3       
## [16] GGally_2.4.0         broom_1.0.12         boot_1.3-32         
## [19] car_3.1-5            carData_3.0-6        lubridate_1.9.5     
## [22] forcats_1.0.1        stringr_1.6.0        dplyr_1.2.0         
## [25] purrr_1.2.1          readr_2.1.6          tidyr_1.3.2         
## [28] tibble_3.3.1         ggplot2_4.0.2        tidyverse_2.0.0     
## 
## loaded via a namespace (and not attached):
##   [1] RColorBrewer_1.1-3      rstudioapi_0.18.0       jsonlite_2.0.0         
##   [4] shape_1.4.6.1           magrittr_2.0.4          TH.data_1.1-5          
##   [7] estimability_2.0.0      jomo_2.7-6              modeltools_0.2-24      
##  [10] magick_2.9.0            farver_2.1.2            nloptr_2.2.1           
##  [13] rmarkdown_2.30          vctrs_0.7.1             minqa_1.2.8            
##  [16] base64enc_0.1-6         rstatix_1.1.0           htmltools_0.5.9        
##  [19] Formula_1.2-5           mitml_0.4-5             sass_0.4.10            
##  [22] bslib_0.10.0            htmlwidgets_1.6.4       plyr_1.8.9             
##  [25] sandwich_3.1-1          zoo_1.8-15              emmeans_2.0.4          
##  [28] cachem_1.1.0            lifecycle_1.0.5         iterators_1.0.14       
##  [31] pkgconfig_2.0.3         Matrix_1.7-4            R6_2.6.1               
##  [34] fastmap_1.2.0           rbibutils_2.4.1         showtext_0.9-8         
##  [37] digest_0.6.39           irlba_2.3.7             textshaping_1.0.4      
##  [40] crosstalk_1.2.2         ggpubr_1.0.0            labeling_0.4.3         
##  [43] timechange_0.4.0        mgcv_1.9-3              abind_1.4-8            
##  [46] compiler_4.5.2          withr_3.0.2             pander_0.6.6           
##  [49] S7_0.2.1                backports_1.5.0         UpSetR_1.4.0           
##  [52] ggstats_0.13.0          ggsignif_0.6.4          pan_1.9                
##  [55] MASS_7.3-65             scatterplot3d_0.3-45    flashClust_1.1-4       
##  [58] tools_4.5.2             otel_0.2.0              prabclus_2.3-5         
##  [61] visdat_0.6.0            nnet_7.3-20             glue_1.8.0             
##  [64] nlme_3.1-168            gridtext_0.1.6          grid_4.5.2             
##  [67] checkmate_2.3.4         reshape2_1.4.5          generics_0.1.4         
##  [70] leaflet.providers_3.0.0 gtable_0.3.6            tzdb_0.5.0             
##  [73] class_7.3-23            hms_1.1.4               xml2_1.5.2             
##  [76] flexmix_2.3-20          ggrepel_0.9.8           foreach_1.5.2          
##  [79] pillar_1.11.1           splines_4.5.2           ggtext_0.1.2           
##  [82] lattice_0.22-7          showtextdb_3.0          survival_3.8-3         
##  [85] tidyselect_1.2.1        reformulas_0.4.4        svglite_2.2.2          
##  [88] stats4_4.5.2            xfun_0.56               diptest_0.77-2         
##  [91] rapportools_1.2         matrixStats_1.5.0       DEoptimR_1.1-4         
##  [94] DT_0.34.0               stringi_1.8.7           yaml_2.3.12            
##  [97] evaluate_1.0.5          codetools_0.2-20        kernlab_0.9-33         
## [100] tcltk_4.5.2             multcompView_0.1-12     cli_3.6.5              
## [103] rpart_4.1.24            xtable_1.8-4            systemfonts_1.3.1      
## [106] Rdpack_2.6.6            jquerylib_0.1.4         Rcpp_1.1.1             
## [109] norm_1.0-11.1           parallel_4.5.2          leaps_3.2              
## [112] mclust_6.1.3            lme4_1.1-38             glmnet_4.1-10          
## [115] viridisLite_0.4.3       mvtnorm_1.3-3           sysfonts_0.8.9         
## [118] scales_1.4.0            crayon_1.5.3            rlang_1.3.0            
## [121] multcomp_1.4-29         mnormt_2.1.2