Una empresa inmobiliaria líder en una gran ciudad requiere comprender el mercado de viviendas urbanas disponibles para tomar decisiones estratégicas de compra, venta y valoración de propiedades. Se cuenta con una base de datos con las características de las propiedades ofertadas (precio, área, ubicación, estrato, tipo, entre otras). Este informe primero describe esa base de datos y luego aplica técnicas de análisis multivariado (Componentes Principales, Conglomerados y Correspondencia) con el fin de identificar patrones que soporten esas decisiones.
Caracterizar la oferta inmobiliaria urbana mediante estadística descriptiva y técnicas multivariadas, identificando los factores que explican la variación de precios y los segmentos relevantes del mercado para apoyar decisiones de inversión.
La base de datos vivienda pertenece al paquete paqueteMODELOS del curso y
contiene propiedades residenciales ofertadas en la ciudad de Cali, obtenidas
mediante webscraping de OLX.
# devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
library(paqueteMODELOS)
library(dplyr)
library(tidyr)
library(ggplot2)
library(gridExtra)
library(knitr)
library(kableExtra)
data(vivienda)
dim(vivienda)## [1] 8322 13
Se renombran las variables originales a nombres cortos y sin tildes ni espacios (en ingles). Esto facilita
escribir y leer el código en las secciones siguientes (por ejemplo price en
vez de preciom), y es el nombre que se usará de aquí en adelante tanto en el
código como en el texto.
vivienda <- vivienda |>
rename(
zone = zona,
floor = piso,
stratum = estrato,
price = preciom,
built_area = areaconst,
parking = parqueaderos,
bathrooms = banios,
bedrooms = habitaciones,
type = tipo,
neighborhood = barrio,
longitude = longitud,
latitude = latitud
)
names(vivienda)## [1] "id" "zone" "floor" "stratum" "price"
## [6] "built_area" "parking" "bathrooms" "bedrooms" "type"
## [11] "neighborhood" "longitude" "latitude"
Tabla 3.1. Descripción y clasificación de las variables (como se vio en la Unidad 1, sesión de Conceptos Generales, sobre naturaleza cualitativa/cuantitativa de las variables)
# Clasificación de variables: CC=Cuant. Continua, CD=Cuant. Discreta,
# CO=Cualitativa Ordinal, CN=Cualitativa Nominal
variables_df <- data.frame(
Variable = c("id", "zone", "floor", "stratum", "price", "built_area",
"parking", "bathrooms", "bedrooms", "type", "neighborhood",
"longitude", "latitude"),
Original = c("id", "zona", "piso", "estrato", "preciom", "areaconst",
"parqueaderos", "banios", "habitaciones", "tipo", "barrio",
"longitud", "latitud"),
Descripcion = c(
"Identificador de la propiedad",
"Zona de la ciudad (Centro, Norte, Oeste, Oriente, Sur)",
"Piso del inmueble (aplica a apartamentos)",
"Estrato socioeconómico",
"Precio de oferta (millones de pesos)",
"Área construida (m2)",
"Número de parqueaderos",
"Número de baños",
"Número de habitaciones",
"Tipo de inmueble (Casa, Apartamento)",
"Barrio de ubicación",
"Coordenada de longitud",
"Coordenada de latitud"
),
Tipo = c("ID", "CN", "CN", "CO", "CC", "CC", "CD", "CD", "CD", "CN", "CN", "CC", "CC")
)
kable(variables_df, col.names = c("Variable", "Nombre original", "Descripción", "Tipo"),
align = c("l", "l", "l", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 13)| Variable | Nombre original | Descripción | Tipo |
|---|---|---|---|
| id | id | Identificador de la propiedad | ID |
| zone | zona | Zona de la ciudad (Centro, Norte, Oeste, Oriente, Sur) | CN |
| floor | piso | Piso del inmueble (aplica a apartamentos) | CN |
| stratum | estrato | Estrato socioeconómico | CO |
| price | preciom | Precio de oferta (millones de pesos) | CC |
| built_area | areaconst | Área construida (m2) | CC |
| parking | parqueaderos | Número de parqueaderos | CD |
| bathrooms | banios | Número de baños | CD |
| bedrooms | habitaciones | Número de habitaciones | CD |
| type | tipo | Tipo de inmueble (Casa, Apartamento) | CN |
| neighborhood | barrio | Barrio de ubicación | CN |
| longitude | longitud | Coordenada de longitud | CC |
| latitude | latitud | Coordenada de latitud | CC |
Antes de aplicar cualquier técnica multivariada se describe el comportamiento individual de las variables, tal como se plantea en la Unidad 1 en la sesión de Conceptos Generales para el análisis univariado.
Tabla 4.1. Media y mediana de las variables cuantitativas
stats_ct <- data.frame(
Variable = vars_num,
Media = sapply(vars_num, function(v) mean(vivienda[[v]], na.rm = TRUE)),
Mediana = sapply(vars_num, function(v) median(vivienda[[v]], na.rm = TRUE))
)
stats_ct |>
mutate(across(c(Media, Mediana), \(x) round(x, 1))) |>
kable(align = c("l", "c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | Media | Mediana | |
|---|---|---|---|
| price | price | 433.9 | 330 |
| built_area | built_area | 174.9 | 123 |
| parking | parking | 1.8 | 2 |
| bathrooms | bathrooms | 3.1 | 3 |
| bedrooms | bedrooms | 3.6 | 3 |
Figura 4.1. Distribución de cada variable cuantitativa, con su media (línea roja) y su mediana (línea azul oscuro)
vivienda |>
select(all_of(vars_num)) |>
pivot_longer(everything(), names_to = "Variable", values_to = "Valor") |>
ggplot(aes(x = Valor)) +
geom_histogram(bins = 30, fill = col_secundario, color = "white", alpha = 0.9) +
geom_vline(data = stats_ct, aes(xintercept = Media), color = "#E15759",
linetype = "dashed", linewidth = 0.7) +
geom_vline(data = stats_ct, aes(xintercept = Mediana), color = "#2C3E50",
linetype = "dashed", linewidth = 0.7) +
facet_wrap(~ Variable, scales = "free", ncol = 3) +
theme_minimal(base_size = 11) +
labs(x = NULL, y = "Frecuencia")price y built_area muestran media notablemente mayor que la mediana (la
línea roja a la derecha de la azul), lo que confirma asimetría hacia la
derecha; en parking, bathrooms y bedrooms ambas líneas están más
cercanas.
Tabla 4.2. Desviación estándar, rango, rango intercuartílico (IQR) y coeficiente de variación (CV)
stats_disp <- data.frame(
Variable = vars_num,
DE = sapply(vars_num, function(v) sd(vivienda[[v]], na.rm = TRUE)),
Min = sapply(vars_num, function(v) min(vivienda[[v]], na.rm = TRUE)),
Max = sapply(vars_num, function(v) max(vivienda[[v]], na.rm = TRUE)),
IQR = sapply(vars_num, function(v) IQR(vivienda[[v]], na.rm = TRUE))
) |>
mutate(CV = round(DE / stats_ct$Media, 2))
stats_disp |>
mutate(across(c(DE, Min, Max, IQR), \(x) round(x, 1))) |>
kable(col.names = c("Variable", "Desv. estándar", "Mín", "Máx", "IQR", "CV"),
align = c("l", rep("c", 5))) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | Desv. estándar | Mín | Máx | IQR | CV | |
|---|---|---|---|---|---|---|
| price | price | 328.6 | 58 | 1999 | 320 | 0.76 |
| built_area | built_area | 143.0 | 30 | 1745 | 149 | 0.82 |
| parking | parking | 1.1 | 1 | 10 | 1 | 0.61 |
| bathrooms | bathrooms | 1.4 | 0 | 10 | 2 | 0.46 |
| bedrooms | bedrooms | 1.5 | 0 | 10 | 1 | 0.40 |
Figura 4.2. Coeficiente de variación por variable (mayor barra = mayor dispersión relativa)
ggplot(stats_disp, aes(x = reorder(Variable, CV), y = CV)) +
geom_col(fill = col_primario) +
coord_flip() +
theme_minimal(base_size = 12) +
labs(x = NULL, y = "Coeficiente de variación (DE / Media)")built_area tiene el coeficiente de variación más alto (0.82), seguido de cerca
por price (0.76): son las dos variables con mayor dispersión relativa;
bathrooms y bedrooms son las más homogéneas entre propiedades.
Tabla 4.3. Frecuencia de propiedades por zona y por tipo de inmueble
zone_counts <- vivienda |> filter(!is.na(zone)) |> count(zone)
type_counts <- vivienda |> filter(!is.na(type)) |> count(type)
zone_counts |>
kable(col.names = c("Zona", "N"), align = c("l", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Zona | N |
|---|---|
| Zona Centro | 124 |
| Zona Norte | 1920 |
| Zona Oeste | 1198 |
| Zona Oriente | 351 |
| Zona Sur | 4726 |
type_counts |>
kable(col.names = c("Tipo", "N"), align = c("l", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Tipo | N |
|---|---|
| Apartamento | 5100 |
| Casa | 3219 |
Figura 4.3. Número de propiedades por zona (izquierda) y por tipo de inmueble (derecha)
p_zone <- ggplot(zone_counts, aes(x = reorder(zone, n), y = n)) +
geom_col(fill = col_primario) + coord_flip() +
theme_minimal(base_size = 11) + labs(x = NULL, y = "N")
p_type <- ggplot(type_counts, aes(x = reorder(type, n), y = n)) +
geom_col(fill = col_secundario) + coord_flip() +
theme_minimal(base_size = 11) + labs(x = NULL, y = "N")
grid.arrange(p_zone, p_type, ncol = 2)Zona Sur concentra la mayor parte de la oferta, y los apartamentos (type) son
más numerosos que las casas.
Tabla 4.4. Datos faltantes por variable
na_df <- data.frame(
Variable = names(vivienda),
Faltantes = colSums(is.na(vivienda))
) |> filter(Faltantes > 0) |>
mutate(Pct = round(Faltantes / nrow(vivienda) * 100, 1))
na_df |>
select(Variable, Faltantes, Pct) |>
kable(col.names = c("Variable", "N faltantes", "% faltantes"), align = c("l", "c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | N faltantes | % faltantes | |
|---|---|---|---|
| id | id | 3 | 0.0 |
| zone | zone | 3 | 0.0 |
| floor | floor | 2638 | 31.7 |
| stratum | stratum | 3 | 0.0 |
| price | price | 2 | 0.0 |
| built_area | built_area | 3 | 0.0 |
| parking | parking | 1605 | 19.3 |
| bathrooms | bathrooms | 3 | 0.0 |
| bedrooms | bedrooms | 3 | 0.0 |
| type | type | 3 | 0.0 |
| neighborhood | neighborhood | 3 | 0.0 |
| longitude | longitude | 3 | 0.0 |
| latitude | latitude | 3 | 0.0 |
Figura 4.4. Porcentaje de datos faltantes por variable
ggplot(na_df, aes(x = reorder(Variable, Pct), y = Pct)) +
geom_col(fill = "#E15759", alpha = 0.85) +
geom_text(aes(label = paste0(Pct, "%")), hjust = -0.1, size = 3.5) +
coord_flip() +
theme_minimal(base_size = 12) +
labs(x = NULL, y = "% de datos faltantes") +
ylim(0, max(na_df$Pct) * 1.3)floor concentra la mayoría de los datos faltantes (no aplica a las casas) y no
se usa en los análisis de esta actividad. parking sí tiene un volumen
relevante de faltantes (19.3%); dado que el ACP y los conglomerados requieren
observaciones completas en las variables cuantitativas (scale()/prcomp() no
admiten NA), se conservan solo los registros completos en esas variables
(vars_num) en vez de imputar, ya que no es el objetivo de esta actividad.
completos <- complete.cases(vivienda[, vars_num])
vivienda_cc <- vivienda[completos, ]
cat("Registros originales:", nrow(vivienda), "\n")## Registros originales: 8322
## Registros completos para ACP y conglomerados: 6717
Se identifican valores atípicos univariados con el criterio del rango intercuartílico, tal como se describió en la Unidad 1 en la sesión de Conceptos Generales:
\[LI = Q_1 - 1.5 \times IQR \qquad LS = Q_3 + 1.5 \times IQR\]
Tabla 4.5. Número y porcentaje de valores atípicos por variable
detect_outliers <- function(x) {
x <- na.omit(x)
q1 <- quantile(x, 0.25); q3 <- quantile(x, 0.75)
iqr <- q3 - q1
li <- q1 - 1.5 * iqr; ls <- q3 + 1.5 * iqr
sum(x < li | x > ls)
}
data.frame(
Variable = vars_num,
N_atipicos = sapply(vars_num, function(v) detect_outliers(vivienda[[v]])),
Pct_atipicos = sapply(vars_num, function(v)
round(detect_outliers(vivienda[[v]]) / sum(!is.na(vivienda[[v]])) * 100, 1))
) |>
kable(col.names = c("Variable", "N atípicos", "% atípicos"), align = c("l", "c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | N atípicos | % atípicos | |
|---|---|---|---|
| price | price | 552 | 6.6 |
| built_area | built_area | 382 | 4.6 |
| parking | parking | 567 | 8.4 |
| bathrooms | bathrooms | 72 | 0.9 |
| bedrooms | bedrooms | 888 | 10.7 |
Figura 4.5. Distribución de las variables cuantitativas (los puntos rojos son valores atípicos según el criterio IQR)
vivienda |>
select(all_of(vars_num)) |>
pivot_longer(cols = everything(), names_to = "Variable", values_to = "Valor") |>
ggplot(aes(x = Variable, y = Valor)) +
geom_boxplot(fill = col_secundario, color = "#2C3E50",
outlier.color = "#E15759", outlier.size = 1.2, alpha = 0.7) +
facet_wrap(~ Variable, scales = "free", ncol = 3) +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank()) +
labs(x = NULL, y = "Valor")price y built_area son las variables con mayor proporción de atípicos, lo
cual es consistente con la existencia de un segmento reducido de propiedades de
lujo. Estos valores no se eliminan: reflejan comportamiento real del mercado y,
como se ve más adelante, ayudan a caracterizar el segmento de mayor valor en el
Análisis de Conglomerados.
La matriz de varianzas-covarianzas \(\Sigma\) resume la variabilidad conjunta de \(p\) variables cuantitativas:
\[\Sigma = \begin{pmatrix} \sigma_1^2 & \sigma_{12} & \cdots & \sigma_{1p} \\ \sigma_{21} & \sigma_2^2 & \cdots & \sigma_{2p} \\ \vdots & \vdots & \ddots & \vdots \\ \sigma_{p1} & \sigma_{p2} & \cdots & \sigma_p^2 \end{pmatrix}\]
A partir de \(\Sigma\) se obtiene la matriz de correlaciones estandarizando por las desviaciones de cada variable, \(R = D^{-1/2}\,\Sigma\,D^{-1/2}\), con \(D\) la matriz diagonal de varianzas. Cada elemento de \(R\) es
\[r_{ij} = \frac{Cov[x_i, x_j]}{\sqrt{Var[x_i]\,Var[x_j]}}, \qquad -1 \le r_{ij} \le 1\]
Como se vio en la Unidad 1 en la sesión de Matriz de Varianza-Covarianza y Correlaciones, valores cercanos a \(\pm 1\) indican relación lineal fuerte y valores cercanos a \(0\) indican ausencia de relación lineal.
Tabla 5.1. Matriz de correlaciones de las variables de la propiedad
## price built_area parking bathrooms bedrooms
## price 1.000 0.687 0.689 0.669 0.264
## built_area 0.687 1.000 0.585 0.648 0.517
## parking 0.689 0.585 1.000 0.571 0.284
## bathrooms 0.669 0.648 0.571 1.000 0.590
## bedrooms 0.264 0.517 0.284 0.590 1.000
Figura 5.1. Matriz de correlaciones (elipses: magnitud; números: coeficiente)
parking y built_area son las variables más asociadas con price
(correlación de 0.689 y 0.687 respectivamente, prácticamente empatadas),
seguidas de bathrooms (0.67). bedrooms es la que menos se relaciona con el
precio (0.26), lo que sugiere que el precio responde más al área y a
comodidades como parqueadero que al número de alcobas.
El primer componente principal es una combinación lineal de las \(p\) variables originales, \(CP_1 = b_1'X = \sum_{i=1}^{p}\beta_{1i}X_i\), cuya varianza es \(V[CP_1] = b_1'\Sigma b_1\), sujeta a la restricción de normalización \(b_1'b_1 = 1\). Para maximizarla se usa el método de los multiplicadores de Lagrange:
\[L = b_1'\Sigma b_1 - \lambda_1(b_1'b_1 - 1)\]
Derivando respecto a \(b_1\) e igualando a cero:
\[\frac{\partial L}{\partial b_1} = 2\Sigma b_1 - 2\lambda_1 b_1 = 0 \;\;\Longrightarrow\;\; \Sigma b_1 = \lambda_1 b_1\]
Es decir, \(b_1\) es un vector propio de \(\Sigma\) y \(\lambda_1\) su valor propio asociado, que corresponde precisamente a \(V[CP_1]\). Los siguientes componentes se obtienen de forma análoga, imponiendo además ortogonalidad (\(Cor[CP_i,CP_j]=0\)) con los anteriores. La proporción de varianza explicada por el componente \(i\) es \(\lambda_i \big/ \sum_{k=1}^{p}\lambda_k\) (Unidad 1, sesión de Análisis de Componentes Principales).
Se estandarizan las variables antes de estimar los componentes (evita que
price, en millones, domine la varianza frente a variables en otras escalas
como parking). Se usa el subconjunto de datos completos vivienda_cc
definido en la Sección 4.4.
## Standard deviations (1, .., p=5):
## [1] 1.8035261 0.9280189 0.6037130 0.5731917 0.4393816
##
## Rotation (n x k) = (5 x 5):
## PC1 PC2 PC3 PC4 PC5
## price 0.4662292 0.41483159 0.3565247 -0.19400036 0.6676819
## built_area 0.4821393 -0.04922953 0.4470892 0.66445370 -0.3517537
## parking 0.4353138 0.42903595 -0.7645826 0.17074090 -0.1126544
## bathrooms 0.4857106 -0.16373543 0.1022653 -0.69486790 -0.4939396
## bedrooms 0.3532126 -0.78397071 -0.2792071 0.09418752 0.4168968
Figura 6.1. Varianza explicada por componente (\(\lambda_i/\sum\lambda_k\))
## PC1 PC2 PC3 PC4 PC5
## Standard deviation 1.804 0.928 0.604 0.573 0.439
## Proportion of Variance 0.651 0.172 0.073 0.066 0.039
## Cumulative Proportion 0.651 0.823 0.896 0.961 1.000
Los dos primeros componentes explican 82.3% de la variabilidad total, lo que permite resumir las 5 variables originales en 2 componentes sin pérdida importante de información.
Figura 6.2. Cargas de las variables en los dos primeros componentes (vector propio asociado a cada eje)
fviz_pca_var(res.pca, col.var = "contrib",
gradient.cols = c("#F2B134", col_primario), repel = TRUE)El primer componente pondera de forma similar y positiva las 5 variables
(cargas entre 0.35 y 0.49): funciona como un eje general de tamaño y precio de
la propiedad. El segundo componente contrasta price y parking (cargas
positivas) frente a bedrooms (carga negativa dominante, -0.78): separa
propiedades caras y con parqueadero de propiedades con muchas alcobas pero
menor precio relativo.
Figura 6.3. Propiedades en el plano de componentes, coloreadas por tipo de inmueble
fviz_pca_ind(res.pca, geom = "point", habillage = vivienda_cc$type,
palette = pal_cat[1:2], alpha.ind = 0.4) +
labs(title = NULL)Las casas (type == "Casa") se ubican hacia valores más altos del primer
componente (propiedades más grandes), mientras que los apartamentos se
concentran en valores bajos a medios.
Para que las variables con escalas distintas no dominen el cálculo de distancias, se estandarizan: \(z = (x-\bar x)/\sigma\). La distancia euclidiana entre dos propiedades \(i,j\) descritas por \(m\) variables estandarizadas es
\[d(x_i, x_j) = \sqrt{\sum_{p=1}^{m}(x_{ip}-x_{jp})^2}\]
El método jerárquico agrupa iterativamente los dos elementos (u objetos) más cercanos; el método k-means, en cambio, busca directamente la partición en \(k\) grupos que minimiza la suma de cuadrados dentro de cada grupo:
\[\text{WCSS} = \sum_{c=1}^{k}\sum_{i \in c} \lVert x_i - \bar x_c \rVert^2\]
con \(\bar x_c\) el centroide del grupo \(c\). Para decidir qué tan bien separado queda cada grupo se usa el índice de Silhouette de una observación \(i\):
\[s(i) = \frac{b(i)-a(i)}{\max\{a(i), b(i)\}}\]
donde \(a(i)\) es la distancia promedio de \(i\) a los demás puntos de su propio grupo y \(b(i)\) la distancia promedio al grupo vecino más cercano (Unidad 1, sesión de Análisis de Conglomerados).
Para el método jerárquico existen varios criterios de enlace (salto mínimo, salto máximo, promedio y agregación de Ward). Se usa el criterio de Ward, que en cada paso fusiona el par de grupos que produce el menor incremento en la WCSS total — el mismo criterio que optimiza k-means — por lo que ambos métodos tienden a ser consistentes entre sí. Otros criterios (como el promedio) son más sensibles a valores atípicos y pueden producir fusiones dominadas por un único punto extremo antes que agrupaciones representativas del conjunto de datos.
Dado el tamaño de la base (6717 propiedades con datos completos), un dendograma jerárquico sobre la totalidad de los datos sería costoso de calcular y de leer; por eso se usa una muestra para decidir el número de segmentos (el método jerárquico permite determinar el número de clases que luego se usará en un método no jerárquico) y después se aplica k-means sobre la totalidad de los datos.
Se toma una muestra de 300 propiedades y se agrupan con enlace de Ward para estimar, mediante el índice de Silhouette, cuántos segmentos existen.
set.seed(1234)
muestra_idx <- sample(nrow(datos_z), 300)
muestra_z <- datos_z[muestra_idx, ]
dist_muestra <- dist(muestra_z, method = "euclidean")
hc_muestra <- hclust(dist_muestra, method = "ward.D2")Tabla 7.1. Índice de Silhouette promedio para distintos valores de k (sobre la muestra de 300, con enlace de Ward)
library(cluster)
sil_k <- sapply(2:6, function(k) {
grupos <- cutree(hc_muestra, k = k)
mean(silhouette(grupos, dist_muestra)[, 3])
})
data.frame(k = 2:6, Silhouette = round(sil_k, 3)) |>
kable(align = c("c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| k | Silhouette |
|---|---|
| 2 | 0.502 |
| 3 | 0.508 |
| 4 | 0.316 |
| 5 | 0.329 |
| 6 | 0.330 |
## [1] 3
3 segmentos maximizan el índice de Silhouette en la muestra, por lo que se usa ese valor de k para segmentar la base completa mediante k-means (método no jerárquico, más eficiente para 6717 observaciones que el jerárquico). Para representar gráficamente el dendograma se usa una submuestra más pequeña (n = 40): con 300 hojas el árbol se vuelve ilegible, mientras que con una muestra pequeña se puede seguir visualmente cómo se van fusionando los grupos hasta llegar a los 3 segmentos.
Figura 7.1. Dendograma sobre una submuestra (n = 40), coloreado según los 3 segmentos elegidos en la Tabla 7.1
muestra_chica <- muestra_z[sample(nrow(muestra_z), 40), ]
hc_chica <- hclust(dist(muestra_chica, method = "euclidean"), method = "ward.D2")
fviz_dend(hc_chica, k = k_opt, cex = 0.6, k_colors = pal_cat[1:k_opt],
rect = TRUE, rect_fill = TRUE, rect_border = pal_cat[1:k_opt],
main = NULL, xlab = "Propiedades", ylab = "Distancia euclidiana")set.seed(1234)
km <- kmeans(datos_z, centers = k_opt, nstart = 25)
vivienda_cc$segment <- factor(km$cluster)
table(vivienda_cc$segment)##
## 1 2 3
## 3763 2129 825
Tabla 7.2. Perfil de cada segmento (medias en la escala original)
etiquetas_segmento <- c("1" = "Económico", "2" = "Medio", "3" = "Premium")
vivienda_cc |>
group_by(segment) |>
summarise(across(all_of(vars_num), mean), n = n()) |>
mutate(across(all_of(vars_num), \(x) round(x, 1)),
Perfil = etiquetas_segmento[as.character(segment)]) |>
relocate(Perfil, .after = segment) |>
kable() |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE)| segment | Perfil | price | built_area | parking | bathrooms | bedrooms | n |
|---|---|---|---|---|---|---|---|
| 1 | Económico | 278.9 | 98.5 | 1.3 | 2.3 | 2.9 | 3763 |
| 2 | Medio | 548.5 | 231.9 | 2.0 | 4.1 | 4.5 | 2129 |
| 3 | Premium | 1129.9 | 426.9 | 3.9 | 5.3 | 4.6 | 825 |
Figura 7.2. Segmentos en el plano de los dos primeros componentes principales (solo el contorno de cada elipse, sin relleno, para no saturar el área donde los segmentos se tocan)
fviz_cluster(list(data = datos_z, cluster = km$cluster),
geom = "point", pointsize = 0.6,
palette = pal_cat, ellipse.type = "norm", ellipse.alpha = 0,
ggtheme = theme_minimal()) +
labs(title = NULL)Los segmentos se diferencian principalmente por tamaño y precio de la
propiedad, en orden creciente: Económico (3763 propiedades),
Medio (2129) y Premium (825) (Tabla 7.2).
El traslape visible entre segmentos adyacentes en la Figura 7.2 (Económico con
Medio, y Medio con Premium) es esperado y no un error: built_area, price y
las demás variables varían de forma continua entre propiedades, no en bloques
discretos, por lo que k-means corta ese continuo en tres tramos con fronteras
necesariamente difusas. El segmento Premium sí se distingue con claridad del
Económico. La relación de estos segmentos con la ubicación geográfica se
evalúa formalmente en la Sección 9.
Para dos variables categóricas A y B se construye una tabla de contingencia \(n_{ij}\) y se compara con la tabla de frecuencias esperadas bajo independencia, \(n_{ij}^{esp} = n_{i.}\,n_{.j}/n\), mediante el estadístico chi-cuadrado de Pearson:
\[\chi^2 = \sum_{i,j} \frac{\left(n_{ij}-n_{ij}^{esp}\right)^2}{n_{ij}^{esp}}\]
A partir de la matriz de discrepancias (o residuos estandarizados) \(C\), el análisis de correspondencia factoriza mediante descomposición en valores singulares \(C = U D V'\), de donde se obtienen las coordenadas de las filas (\(U\sqrt{D}\)) y de las columnas (\(V\sqrt{D}\)) que se representan en el plano factorial, tal como se vio en la Unidad 1 en la sesión de Análisis de Correspondencia.
##
## Apartamento Casa
## Zona Centro 24 100
## Zona Norte 1198 722
## Zona Oeste 1029 169
## Zona Oriente 62 289
## Zona Sur 2787 1939
##
## Pearson's Chi-squared test
##
## data: tabla_zona_tipo
## X-squared = 690.93, df = 4, p-value < 0.00000000000000022
# type solo tiene 2 categorías, por lo que el AC entre zone y type tiene un
# único eje (min(filas-1, columnas-1) = 1); no aplica un mapa 2D, se reportan
# las coordenadas de ese único eje
res.ca1 <- CA(tabla_zona_tipo, graph = FALSE)
round(res.ca1$row$coord, 3)## Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
## -0.861 0.022 0.505 -0.896 -0.048
## [,1]
## Apartamento 0.229
## Casa -0.363
Figura 8.1. Proporción de tipo de inmueble por zona
vivienda |>
filter(!is.na(zone), !is.na(type)) |>
count(zone, type) |>
group_by(zone) |>
mutate(pct = n / sum(n)) |>
ggplot(aes(x = zone, y = pct, fill = type)) +
geom_col(position = "fill") +
scale_fill_manual(values = pal_cat[1:2]) +
scale_y_continuous(labels = scales::percent) +
coord_flip() +
theme_minimal(base_size = 12) +
labs(x = NULL, y = "Proporción", fill = "Tipo")El p-value rechaza la independencia entre zone y type. Las coordenadas del
único eje del AC y la Figura 8.1 muestran que Zona Oriente y Zona Centro están
más asociadas a casas, mientras que Zona Oeste concentra una mayor proporción
de apartamentos.
Dado que neighborhood tiene 437
categorías, se conservan los 15 barrios con mayor número de propiedades y se
agrupa el resto como “Otros” para que el mapa de correspondencia sea legible.
top_barrios <- vivienda |>
count(neighborhood, sort = TRUE) |>
slice_head(n = 15) |>
pull(neighborhood)
vivienda$neighborhood_grouped <- ifelse(vivienda$neighborhood %in% top_barrios,
vivienda$neighborhood, "Otros")
tabla_zona_barrio <- table(vivienda$zone, vivienda$neighborhood_grouped)
chisq.test(tabla_zona_barrio)##
## Pearson's Chi-squared test
##
## data: tabla_zona_barrio
## X-squared = 8502.9, df = 60, p-value < 0.00000000000000022
Figura 8.2. Mapa de correspondencia entre zona y barrio
res.ca2 <- CA(tabla_zona_barrio, graph = FALSE)
fviz_ca_biplot(res.ca2, repel = TRUE) + labs(title = NULL)## eigenvalue percentage of variance cumulative percentage of variance
## dim 1 0.55448536669 54.249537667 54.24954
## dim 2 0.41655448536 40.754706258 95.00424
## dim 3 0.05102153994 4.991826870 99.99607
## dim 4 0.00004016046 0.003929204 100.00000
Los dos primeros ejes concentran la mayor parte de la varianza explicada, y el mapa evidencia que cada zona tiene barrios característicos, lo cual es esperable dado que los barrios están anidados dentro de una única zona de la ciudad.
Se usan longitude y latitude para ubicar las propiedades y contrastar
visualmente la zona (Figura 9.2) con el segmento de conglomerados (Figura 9.1).
Cada segmento se muestra en un panel separado (en vez de superponer los tres
colores en un mismo mapa) porque, al estar presentes en toda la ciudad, los
puntos de los tres segmentos se solapan demasiado si se dibujan juntos.
Figura 9.1. Ubicación de las propiedades por segmento (un panel por segmento)
ggplot(vivienda_cc, aes(x = longitude, y = latitude, color = segment)) +
geom_point(alpha = 0.5, size = 0.6) +
scale_color_manual(values = pal_cat, labels = etiquetas_segmento) +
coord_fixed() +
facet_wrap(~ segment, ncol = 3, labeller = as_labeller(etiquetas_segmento)) +
theme_minimal(base_size = 11) +
theme(legend.position = "none") +
labs(x = "Longitud", y = "Latitud")Figura 9.2. Ubicación de las propiedades por zona
ggplot(vivienda, aes(x = longitude, y = latitude, color = zone)) +
geom_point(alpha = 0.4, size = 0.8) +
scale_color_manual(values = pal_cat) +
coord_fixed() +
theme_minimal(base_size = 12) +
labs(x = "Longitud", y = "Latitud", color = "Zona")A simple vista la Figura 9.1 podría sugerir que cada segmento tiene su propia zona, ya que los tres paneles muestran algo de agrupamiento espacial. Sin embargo, el segmento se calculó únicamente con variables físicas de la propiedad (precio, área, parqueaderos, baños, habitaciones) — la ubicación no participó en ese cálculo — por lo que conviene verificar la relación real con una tabla, en vez de comparar los mapas a simple vista.
Tabla 9.1. Composición de cada zona por segmento (% de columna)
tab_seg_zona <- table(vivienda_cc$segment, vivienda_cc$zone)
pct_seg_zona <- round(prop.table(tab_seg_zona, margin = 2) * 100, 1) |>
as.data.frame.matrix()
pct_seg_zona <- cbind(Segmento = etiquetas_segmento[rownames(pct_seg_zona)],
pct_seg_zona)
kable(pct_seg_zona, align = c("c", rep("c", 5)), row.names = FALSE) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Segmento | Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur |
|---|---|---|---|---|---|
| Económico | 46.9 | 65.2 | 40.0 | 44.8 | 58.0 |
| Medio | 50.0 | 27.8 | 39.7 | 52.8 | 29.6 |
| Premium | 3.1 | 7.0 | 20.3 | 2.5 | 12.3 |
##
## Pearson's Chi-squared test
##
## data: tab_seg_zona
## X-squared = 244.8, df = 8, p-value < 0.00000000000000022
La Tabla 9.1 muestra que el segmento Económico domina en Zona Norte (65.2%) y Zona Sur (58.0%); el segmento Medio es más frecuente en Zona Centro (50.0%) y Zona Oriente (52.8%); y el segmento Premium, aunque minoritario en toda la ciudad, se concentra notablemente más en Zona Oeste (20.3% de sus propiedades) que en el resto (2.5% a 12.3%). La prueba chi-cuadrado rechaza la independencia entre segmento y zona (\(\chi^2=\) 244.8, p < 0.001). En conclusión, la ubicación no determina por sí sola el segmento de una propiedad — los tres segmentos están presentes en las cinco zonas — pero sí existe una asociación real, y Zona Oeste destaca como la zona con mayor concentración relativa de oferta premium.
El objetivo de la empresa es claro: usar estos datos para ganar ventaja competitiva, invertir mejor y aumentar la rentabilidad en un mercado que se mueve rápido. Con lo desarrollado en las secciones anteriores respondo aquí a las tres preguntas que realmente le sirven para decidir.
No es una sola variable, es tamaño construido, número de baños, parqueaderos y habitaciones moviéndose juntos: ese eje único explica el 65% de la variabilidad de toda la oferta (Sección 6, Figura 6.1). El precio en sí no es el mejor indicador para valorar una propiedad de forma aislada — el área construida y el número de parqueaderos lo explican mejor (correlación 0.68 y 0.69, Sección 5). Para tasar o negociar un inmueble, la empresa debería mirar primero estas dos variables antes que compararlo solo por precio de lista.
El mercado no se divide en dos, se divide en tres: Económico (3763 propiedades, ~279M y 98 m² en promedio), Medio (2129, ~548M y 232 m²) y Premium (825, ~1.130M y 427 m²) (Tabla 7.2). Son solo el 12.3% del inventario pero mueven precios que multiplican por 4 el segmento económico — ahí está el margen, no en el volumen. Tratar estos tres grupos con la misma estrategia comercial es dejar plata sobre la mesa.
En Zona Oeste. Ahí el segmento Premium representa el 20.3% de las propiedades, frente a apenas 2.5%-12.3% en el resto de la ciudad (Tabla 9.1, \(\chi^2=\) 244.8, p < 0.001) — es la única zona donde lo premium no es la excepción sino una porción real del mercado. Esa misma zona además concentra apartamentos más que casas (Sección 8.1), así que el tipo de inventario a priorizar ahí es claro. Zona Norte y Zona Sur, en cambio, son volumen del segmento económico: sirven para rotación rápida, no para margen.
En resumen: la empresa gana más analizando tamaño de propiedad (no precio de lista), tratando sus tres segmentos por separado, y apostando su inversión premium en Zona Oeste en vez de repartirla parejo por la ciudad.
## R version 4.6.1 (2026-06-24 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 cluster_2.1.8.3 factoextra_2.2.0
## [4] corrplot_0.95 kableExtra_1.4.1 tidyr_1.3.2
## [7] dplyr_1.2.1 paqueteMODELOS_0.1.0 summarytools_1.1.5
## [10] knitr_1.51 gridExtra_2.3.1 GGally_2.4.0
## [13] ggplot2_4.0.3 broom_1.0.13 boot_1.3-32
##
## loaded via a namespace (and not attached):
## [1] tidyselect_1.2.1 viridisLite_0.4.3 farver_2.1.2
## [4] viridis_0.6.5 S7_0.2.2 fastmap_1.2.0
## [7] digest_0.6.39 estimability_2.0.0 timechange_0.4.0
## [10] lifecycle_1.0.5 multcompView_0.1-12 magrittr_2.0.5
## [13] compiler_4.6.1 rlang_1.3.0 sass_0.4.10
## [16] tools_4.6.1 yaml_2.3.12 ggsignif_0.6.4
## [19] labeling_0.4.3 htmlwidgets_1.6.4 scatterplot3d_0.3-45
## [22] showtextdb_3.0 plyr_1.8.9 xml2_1.6.0
## [25] RColorBrewer_1.1-3 abind_1.4-8 withr_3.0.3
## [28] purrr_1.2.2 grid_4.6.1 ggpubr_1.0.0
## [31] sysfonts_0.8.9 emmeans_2.0.4 scales_1.4.0
## [34] MASS_7.3-65 flashClust_1.1-4 mvtnorm_1.4-2
## [37] cli_3.6.6 rmarkdown_2.31 generics_0.1.4
## [40] otel_0.2.0 rstudioapi_0.19.0 reshape2_1.4.5
## [43] cachem_1.1.0 pander_0.6.6 stringr_1.6.0
## [46] matrixStats_1.5.0 base64enc_0.1-6 vctrs_0.7.3
## [49] Matrix_1.7-5 jsonlite_2.0.0 carData_3.0-6
## [52] bookdown_0.47 car_3.1-5 rapportools_1.2
## [55] ggrepel_0.9.8 rstatix_1.1.0 irlba_2.3.7
## [58] Formula_1.2-6 systemfonts_1.3.2 magick_2.9.1
## [61] dendextend_1.19.1 jquerylib_0.1.4 glue_1.8.1
## [64] ggstats_0.13.0 ggtext_0.1.2 DT_0.34.0
## [67] lubridate_1.9.5 stringi_1.8.9 gtable_0.3.6
## [70] tibble_3.3.1 pillar_1.11.1 htmltools_0.5.9
## [73] showtext_0.9-8 R6_2.6.1 tcltk_4.6.1
## [76] textshaping_1.0.5 lattice_0.22-9 evaluate_1.0.5
## [79] leaps_3.2 backports_1.5.1 gridtext_0.1.6
## [82] bslib_0.12.0 Rcpp_1.1.2 svglite_2.2.2
## [85] checkmate_2.3.4 xfun_0.60 pkgconfig_2.0.3