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.
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.
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.
## 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 |
## 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.
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)\) | Sí, 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.
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.
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.
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.
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.
md.pattern(vivienda, rotate.names = TRUE)
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.
pisovivienda |>
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)
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.
# 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.
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.
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.
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.
| 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.
pisoEstablecido 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%
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.
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.
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.
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.
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.
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.
| 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.
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)
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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)
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:
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.# 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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
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.
pisovivienda <- 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.
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.
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.
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 |
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.
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.
| 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")
| 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.
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")
| 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.
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")
| 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.
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")
| 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")
| 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")
| 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.
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")
| 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.
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.
## 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.
| 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.
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")
| 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.
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.
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.
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")
| 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
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.
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.
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
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.
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")
| 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.
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)")
| 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)")
| 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.
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")
| 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
| 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")
| Observaciones | Componentes retenidas | Variables de apoyo | Varianza retenida (%) | Varianza descartada (%) |
|---|---|---|---|---|
| 8.232 | 2 | 5 | 82,3 | 17,7 |
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.
| 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
| 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")
| 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.
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.
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.
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.
| 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).
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")
| 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.
# 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")
| 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.
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")
| 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")
| 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")
| 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.
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.
El perfilamiento permite identificar tres segmentos caracterizados:
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.
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
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.
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.
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")
| 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.
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.
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")
| 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.
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")
| 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 |
| 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.
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.
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")
| 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")
| 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")
| 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.
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.
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")
| 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")
| 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.
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")
| 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.
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")
| 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")
| 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.
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)")
| 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).
| 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")
| 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")
| 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.
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")
| 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.
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.
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")
| 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.
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.
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')
| 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.
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.
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.
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.
| 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 |
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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