Taller 4 - Tópicos y Minería de Datos
Introducción
En esta tarea abordamos la segmentación de viviendas mediante técnicas no supervisadas. El objetivo es construir tipologías útiles para análisis descriptivo y para insumos posteriores de modelado (p. ej., pricing). Nos enfocamos en dos escenarios:
Sólo variables continuas → comparación entre K-medias y DBSCAN.
Variables mixtas (numéricas + categóricas) → K-Prototypes.
Buenas prácticas que aplicaremos:
Preparación: inspección de NA, outliers, y escalamiento.
Selección de variables: evitamos colinealidad alta para no sobreponderar factores redundantes.
Validación interna: Silhouette, WSS (codo) y Gap Statistic para K-means; kNN-dist plot para elegir eps en DBSCAN.
Perfilamiento: resúmenes por clúster que permitan interpretar y comunicar hallazgos
Librería
Punto 1
Carga y estructura del conjunto de datos
Se realiza la importación del conjunto de datos housing y, como primer paso, se inspecciona su estructura interna para verificar el tipo de dato de cada columna.
## spc_tbl_ [20,640 × 10] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
## $ longitude : num [1:20640] -122 -122 -122 -122 -122 ...
## $ latitude : num [1:20640] 37.9 37.9 37.9 37.9 37.9 ...
## $ housing_median_age: num [1:20640] 41 21 52 52 52 52 52 52 42 52 ...
## $ total_rooms : num [1:20640] 880 7099 1467 1274 1627 ...
## $ total_bedrooms : num [1:20640] 129 1106 190 235 280 ...
## $ population : num [1:20640] 322 2401 496 558 565 ...
## $ households : num [1:20640] 126 1138 177 219 259 ...
## $ median_income : num [1:20640] 8.33 8.3 7.26 5.64 3.85 ...
## $ median_house_value: num [1:20640] 452600 358500 352100 341300 342200 ...
## $ ocean_proximity : chr [1:20640] "NEAR BAY" "NEAR BAY" "NEAR BAY" "NEAR BAY" ...
## - attr(*, "spec")=
## .. cols(
## .. longitude = col_double(),
## .. latitude = col_double(),
## .. housing_median_age = col_double(),
## .. total_rooms = col_double(),
## .. total_bedrooms = col_double(),
## .. population = col_double(),
## .. households = col_double(),
## .. median_income = col_double(),
## .. median_house_value = col_double(),
## .. ocean_proximity = col_character()
## .. )
## - attr(*, "problems")=<externalptr>
A partir del conjunto de datos cargado, el siguiente paso consiste en diferenciar las variables por tipo. Dado que el objetivo inicial es aplicar algoritmos de clustering basados en medidas de distancia euclidiana (como K-medias o DBSCAN), resulta fundamental concentrarnos en las variables continuas numéricas, ya que este tipo de variables pueden ser estandarizadas, comparadas entre sí y utilizadas directamente en la construcción de centroides o en el cálculo de densidades.
Para este caso, se identifican como continuas aquellas variables que miden cantidades cuantitativas y escalables, tales como:
Longitude y Latitude: representan la ubicación geográfica de cada observación en el espacio; son datos continuos que permiten capturar patrones espaciales o regionales.
Housing median age: edad mediana de las viviendas en la zona; es numérica y refleja la antigüedad del parque habitacional.
Total rooms, total bedrooms, population y households: describen el tamaño físico y demográfico de las viviendas y de los hogares asociados. Aunque suelen estar altamente correlacionadas, inicialmente se consideran como variables potenciales.
Median income y median house value: son indicadores económicos clave que reflejan, respectivamente, la capacidad adquisitiva de los hogares y el precio de mercado de las viviendas.
Al enfocarnos en estas variables continuas, se garantiza que los algoritmos puedan detectar similitudes y diferencias cuantitativas entre observaciones, construyendo así clústeres más representativos. En fases posteriores se analizará la correlación entre ellas, con el fin de evitar redundancia e introducir únicamente las más informativas para el modelo, reduciendo así el riesgo de sobreponderar dimensiones altamente colineales.
housing_cont <- housing |>
dplyr::select(longitude, latitude, housing_median_age,
total_rooms, total_bedrooms, population,
households, median_income, median_house_value)
head(housing_cont)
Matriz de correlación
Se calcula la matriz de correlación de las variables continuas para identificar aquellas con alta colinealidad. Las variables fuertemente correlacionadas se excluyen del análisis, evitando redundancias y asegurando que el modelo de clustering se base únicamente en atributos informativos y no repetidos.
cor_matrix <- cor(housing_cont, use = "pairwise.complete.obs")
corrplot(cor_matrix,
method = "color",
type = "upper",
tl.cex = 0.7,
col = colorRampPalette(c("darkblue", "white", "darkred"))(200))
A partir del gráfico se destacan los siguientes hallazgos:
Existe una alta correlación positiva entre total_rooms, total_bedrooms, population y households. Esto evidencia multicolinealidad, ya que estas variables describen aspectos relacionados con el tamaño de los hogares y tienden a aportar información redundante.
La variable median_income presenta una correlación positiva moderada-alta con median_house_value, lo cual es consistente con la lógica económica: a mayores ingresos, mayor valor medio de la vivienda.
Las variables geográficas (longitude y latitude) no muestran correlaciones fuertes con la mayoría de las demás, pero aportan información espacial relevante.
housing_median_age presenta correlaciones débiles con la mayoría de las demás variables, indicando que la antigüedad de las viviendas aporta una dimensión distinta que no se solapa con las demás.
En conclusión, el análisis de correlación sugiere excluir variables redundantes como total_rooms, total_bedrooms y households, conservando en cambio aquellas con mayor valor explicativo e independiente: housing_median_age, population, median_income y median_house_value. Esto permite construir modelos de clustering más robustos y sin sesgo por multicolinealidad.
# Escalamiento de variables seleccionadas
vars_selecc <- housing_cont |>
dplyr::select(housing_median_age, population,
median_income, median_house_value) |>
scale() |>
as.data.frame()
head(vars_selecc)
## 'data.frame': 20640 obs. of 4 variables:
## $ housing_median_age: num 0.982 -0.607 1.856 1.856 1.856 ...
## $ population : num -0.974 0.861 -0.821 -0.766 -0.76 ...
## $ median_income : num 2.3447 2.3322 1.7827 0.9329 -0.0129 ...
## $ median_house_value: num 2.13 1.31 1.26 1.17 1.17 ...
Con el fin de preparar la información para la aplicación de algoritmos de clustering, se seleccionaron las variables más representativas (housing_median_age, population, median_income y median_house_value) y se les aplicó un escalamiento tipo z-score. Esta transformación normaliza cada variable a una media de 0 y una desviación estándar de 1, asegurando que todas contribuyan de manera equitativa en el cálculo de distancias.
En la tabla resultante se observan valores positivos y negativos que reflejan la posición relativa de cada observación respecto al promedio:
Valores positivos indican registros por encima de la media de la base de datos.
Valores negativos corresponden a registros por debajo de la media.
Por ejemplo, en la primera fila el ingreso medio (median_income) presenta un valor de 2.34 y el valor medio de la vivienda (median_house_value) de 2.12, lo cual señala que dicha observación se encuentra más de dos desviaciones estándar por encima del promedio en ambas variables. En contraste, la población (population) de esa misma observación tiene un valor de -0.97, es decir, se ubica por debajo del promedio poblacional.
Este procedimiento es fundamental para evitar que variables con escalas mayores (como población o valor de vivienda) dominen sobre otras de menor magnitud (como edad mediana de la vivienda), logrando así que los algoritmos de clustering generen grupos más coherentes, comparables y balanceados.
Aplicación de métodos de clustering - Grupos
El análisis para determinar el número óptimo de clústeres combina distintos criterios, ya que cada método ofrece una perspectiva complementaria:
# Método Silhoute
set.seed(12345)
fviz_nbclust(vars_selecc, kmeans, method = "silhouette", k.max = 10)
Método del silhouette: evalúa la calidad de la separación entre grupos. Un valor promedio más alto indica que las observaciones están bien asignadas a su clúster y alejadas de los demás. En este caso, el valor máximo se alcanza con k = 2, lo que sugiere que dividir la muestra en dos clústeres genera una partición clara y consistente.
## Método de distancia cuadrada dentro (método del codo)
set.seed(12345)
fviz_nbclust(vars_selecc, kmeans, method = "wss", k.max = 10)
Método del codo (WSS – Within Sum of Squares): este criterio evalúa la inercia intra-grupo, es decir, la suma de distancias de cada observación respecto a su centroide. A medida que aumenta el número de clústeres, el WSS disminuye de forma progresiva; sin embargo, llega un punto en que la reducción deja de ser significativa y la curva forma un “codo”. Ese punto de inflexión indica el número óptimo de clústeres, ya que refleja un equilibrio entre simplicidad y capacidad explicativa del modelo.
En este caso, la curva no muestra un quiebre muy marcado, aunque se aprecia una ligera inflexión alrededor de k = 3, lo cual sugiere que tres clústeres podrían ser una solución adecuada. No obstante, el método del silhouette recomienda k = 2, generando resultados divergentes. Ante esta discrepancia, se decidió probar con k = 4, evaluando no solo la calidad estadística de la partición, sino también la coherencia e interpretabilidad de los perfiles obtenidos. Este enfoque comparativo permite alcanzar un balance entre la parsimonia del modelo y la riqueza descriptiva necesaria para el análisis.
K-medias
set.seed(12345)
kmedias <- kmeans(vars_selecc, 4)
vars_selecc$grupo_kmedias <- as.character(kmedias$cluster)
ggplot(vars_selecc, aes(x = median_income, y = median_house_value, color = grupo_kmedias)) +
geom_point(alpha = 0.6, size = 1) +
labs(title = "K-medias (k=4)",
x = "Median income (scaled)",
y = "Median house value (scaled)") +
scale_color_manual(values = c("green", "orange", "#7570b3", "#e7298a")) +
theme_minimal()
La gráfica del algoritmo K-medias con k = 4 muestra cómo las observaciones se agrupan en cuatro clústeres diferenciados a partir de las variables escaladas de ingreso medio y valor medio de la vivienda. Se confirma una relación positiva entre ambas: a mayores ingresos corresponden viviendas de mayor valor. El modelo identifica distintos segmentos socioeconómicos: un grupo de hogares con bajos ingresos y bajo valor de vivienda, dos grupos intermedios que se traslapan parcialmente y reflejan niveles medios, y un grupo reducido en la parte superior con altos ingresos y viviendas costosas. Aunque existe cierta superposición en los rangos intermedios, la segmentación obtenida resulta coherente y permite sintetizar la estructura de la base en perfiles representativos del mercado de vivienda.
DBSCAN
## DBSCAN
# Selección de variables continuas
datos_db <- housing |>
dplyr::select(housing_median_age, population, median_income, median_house_value) |>
na.omit()
# Escalamiento
X <- scale(datos_db)
# Ajuste de DBSCAN
set.seed(123)
dbscan_model <- dbscan(X, eps = 0.3, minPts = 8)
# Resultados
table(dbscan_model$cluster)
##
## 0 1 2 3 4 5 6 7 8 9 10 11 12
## 3718 16583 5 36 8 11 14 8 7 7 7 25 18
## 13 14 15 16 17 18 19 20 21 22 23 24 25
## 20 10 5 17 7 25 8 17 6 9 8 7 4
## 26 27 28 29 30 31
## 8 11 8 11 8 4
# Agregar clúster al dataset
datos_db$cluster_db <- as.factor(dbscan_model$cluster)
# Visualización
ggplot(datos_db, aes(x = median_income, y = median_house_value, color = cluster_db)) +
geom_point(alpha = 0.7, size = 1.5) +
scale_color_brewer(palette = "Pastel1") +
labs(title = "Clasificación con DBSCAN (4 grupos)",
x = "Median income",
y = "Median house value",
color = "Cluster") +
theme_minimal()
El gráfico de DBSCAN con 4 grupos muestra que la mayor parte de las observaciones se concentran en un clúster principal, mientras que el resto se distribuye en pequeños subgrupos y en puntos clasificados como ruido. A diferencia de K-medias, donde los segmentos resultan más equilibrados, DBSCAN revela un patrón dominado por un conglomerado central de viviendas con niveles de ingreso y valor similares, acompañado de grupos minoritarios que reflejan casos atípicos o áreas con menor densidad poblacional dentro de la base.
Punto 2
Curva de Codo
La curva de codo permite identificar el número óptimo de clústeres analizando la suma de cuadrados intra-grupo (WSS). A medida que se incrementa el número de clústeres, el WSS disminuye porque los grupos se ajustan mejor a los datos; sin embargo, llega un punto en el que esa reducción deja de ser significativa y la curva forma un quiebre o “codo”. Ese punto marca el balance entre simplicidad del modelo y capacidad de representación.
##Curva de Codo para encontrar el valor óptimo de K (K-PROTOTYPES)
if(!exists("data")||is.function(data)){
if(exists("housing")) data<-housing else if(exists("housing_cat")) data<-housing_cat else stop("Define 'data' con tu dataset (p.ej., data <- housing).")
}
data<-as.data.frame(data)
if(!NROW(data)) stop("'data' no tiene filas.")
cost<-numeric()#Vector para almacenar el costo
K<-1:4#Rango de K a probar
set.seed(123)#Para reproducibilidad
#Identificar columnas numéricas y categóricas
num_cols<-names(data)[sapply(data,is.numeric)]
cat_cols<-names(data)[sapply(data,is.factor)]
lambda<-1#peso de variables categóricas
for(num_clusters in K){
if(NROW(data)<num_clusters) stop("K > nrow(data).")#FIX:evita muestrear más filas de las que hay
#Inicializar prototipos aleatorios
proto_idx<-sample.int(NROW(data),num_clusters)
proto_num<-if(length(num_cols)) data[proto_idx,num_cols,drop=FALSE] else NULL
proto_cat<-if(length(cat_cols)) data[proto_idx,cat_cols,drop=FALSE] else NULL
#Asignar clústeres
clusters<-rep(NA_integer_,NROW(data))
for(i in 1:NROW(data)){
dists<-numeric(num_clusters)
for(k in 1:num_clusters){
d_num<-if(length(num_cols)) sum((as.numeric(data[i,num_cols])-as.numeric(proto_num[k,,drop=FALSE]))^2,na.rm=TRUE) else 0
mism<-if(length(cat_cols)) {
xi<-as.character(data[i,cat_cols,drop=FALSE][1,])
pj<-as.character(proto_cat[k,cat_cols,drop=FALSE][1,])
sum(xi!=pj,na.rm=TRUE)
} else 0
dists[k]<-d_num+lambda*mism
}
if(anyNA(dists)) dists[is.na(dists)]<-Inf#FIX:evita which.min con NA
clusters[i]<-which.min(dists)
}
#CÁLCULO MANUAL DEL COSTO
current_cost<-0
for(i in 1:NROW(data)){
k<-clusters[i]
d_num<-if(length(num_cols)) sum((as.numeric(data[i,num_cols])-as.numeric(proto_num[k,,drop=FALSE]))^2,na.rm=TRUE) else 0
mism<-if(length(cat_cols)) {
xi<-as.character(data[i,cat_cols,drop=FALSE][1,])
pj<-as.character(proto_cat[k,cat_cols,drop=FALSE][1,])
sum(xi!=pj,na.rm=TRUE)
} else 0
current_cost<-current_cost+d_num+lambda*mism
}
cost[num_clusters]<-current_cost
}
#Dataframe
elbow_data<-data.frame(K=K,Cost=cost)
#Gráfica de codo
ggplot(elbow_data,aes(x=K,y=Cost))+
geom_line(color="purple")+
geom_point(color="purple",size=3)+
labs(
title="Método del Codo para K óptimo (K-Prototypes)",
x="Número de clústeres (K)",
y="Costo total"
)+
theme_minimal()
La curva de codo muestra cómo evoluciona el costo total (suma de distancias mixtas entre observaciones y centroides) a medida que aumenta el número de clústeres. Se observa una reducción progresiva del costo al pasar de K = 1 a K = 4, pero el cambio más significativo ocurre entre K = 2 y K = 3, donde la caída es muy pronunciada. A partir de K = 3, la disminución del costo es marginal, lo que indica que añadir más clústeres no aporta una mejora sustancial en la homogeneidad de los grupos.
En este sentido, el punto de inflexión o “codo” se identifica en K = 3, lo que sugiere que este es el número más adecuado de clústeres para representar la estructura de los datos de manera equilibrada entre simplicidad e interpretabilidad.
Modelo final con K-Prototypes (3 grupos)
El modelo final con K-Prototypes (K = 3) permite integrar simultáneamente variables numéricas y categóricas, ofreciendo una segmentación más completa que los métodos basados solo en variables continuas. El algoritmo generó tres clústeres con tamaños desiguales: uno mayoritario que concentra más de la mitad de las observaciones y dos grupos más pequeños que representan perfiles diferenciados de vivienda.
### Modelo final con K-Prototypes (3 grupos)
set.seed(42)#Semilla para reproducibilidad
num_clusters <- 3
lambda <- 1#Peso de categóricas
#Inicializar prototipos aleatorios
proto_idx <- sample.int(nrow(data), num_clusters) #FIX
proto_num <- data[proto_idx, num_cols, drop = FALSE]
proto_cat <- data[proto_idx, cat_cols, drop = FALSE]
#Asignar clúster a cada observación
clusters <- rep(NA, nrow(data))
for (i in 1:nrow(data)) {
dists <- numeric(num_clusters)
for (k in 1:num_clusters) {
d_num <- sum((as.numeric(data[i, num_cols]) - as.numeric(proto_num[k, ]))^2, na.rm = TRUE)
mism <- sum(as.character(data[i, cat_cols]) != as.character(proto_cat[k, ]), na.rm = TRUE)
dists[k] <- d_num + lambda * mism }
if (anyNA(dists)) dists[is.na(dists)] <- Inf
clusters[i] <- which.min(dists)
}
print(table(clusters))
## clusters
## 1 2 3
## 3046 3800 13794
#Añadir el clúster al dataset
data_clustered <- data |>
mutate(Cluster = clusters)
print(head(data_clustered,10))
## longitude latitude housing_median_age total_rooms total_bedrooms population
## 1 -122.23 37.88 41 880 129 322
## 2 -122.22 37.86 21 7099 1106 2401
## 3 -122.24 37.85 52 1467 190 496
## 4 -122.25 37.85 52 1274 235 558
## 5 -122.25 37.85 52 1627 280 565
## 6 -122.25 37.85 52 919 213 413
## 7 -122.25 37.84 52 2535 489 1094
## 8 -122.25 37.84 52 3104 687 1157
## 9 -122.26 37.84 42 2555 665 1206
## 10 -122.25 37.84 52 3549 707 1551
## households median_income median_house_value ocean_proximity Cluster
## 1 126 8.3252 452600 NEAR BAY 3
## 2 1138 8.3014 358500 NEAR BAY 3
## 3 177 7.2574 352100 NEAR BAY 3
## 4 219 5.6431 341300 NEAR BAY 3
## 5 259 3.8462 342200 NEAR BAY 3
## 6 193 4.0368 269700 NEAR BAY 3
## 7 514 3.6591 299200 NEAR BAY 3
## 8 647 3.1200 241400 NEAR BAY 3
## 9 595 2.0804 226700 NEAR BAY 3
## 10 714 3.6912 261100 NEAR BAY 3
El 66,8% de las viviendas se concentró en el Grupo 3 (13.794 casos), mientras que el 18,4% quedó en el Grupo 2 (3.800 casos) y el 14,8% en el Grupo 1 (3.046 casos), sobre un total de 20.640 observaciones. La distribución muestra que la partición está fuertemente cargada hacia el Grupo 3, lo que evidencia un clúster dominante y dos grupos minoritarios.
Se recomienda realizar un perfilamiento detallado de cada grupo (medias para variables numéricas y modas para variables categóricas) con el fin de interpretar mejor sus características. Asimismo, si se busca una mayor equilibrio en el tamaño de los clústeres, conviene probar otros valores de K o ajustar el parámetro λ, que controla el peso relativo de las variables categóricas en el algoritmo K-Prototypes.
Aplicación cluster.carac
#Usa la columna de clúster que creaste
cl <- as.factor(data_clustered$Cluster)
#Variable continuas
cluster.carac(tabla = data_clustered[sapply(data_clustered, is.numeric)],
class = cl, tipo.v = "continuas")
## class: 1
## Test.Value Class.Mean Frequency Global.Mean
## latitude 5.444 35.826 3046 35.632
## population 4.736 1515.208 3046 1425.477
## longitude 3.896 -119.439 3046 -119.570
## housing_median_age -8.375 26.876 3046 28.639
## median_income -28.483 2.965 3046 3.871
## median_house_value -43.849 122208.470 3046 206855.817
## Cluster -123.166 1.000 3046 2.521
## ------------------------------------------------------------
## class: 2
## Test.Value Class.Mean Frequency Global.Mean
## latitude 32.739 36.657 3800 35.632
## longitude -7.496 -119.790 3800 -119.570
## population -12.835 1212.495 3800 1425.477
## households -17.050 404.022 3800 499.540
## total_rooms -18.963 2029.573 3800 2635.763
## Cluster -48.150 2.000 3800 2.521
## median_income -55.677 2.321 3800 3.871
## median_house_value -76.540 77435.262 3800 206855.817
## ------------------------------------------------------------
## class: 3
## Test.Value Class.Mean Frequency Global.Mean
## Cluster 132.421 3.000 13794 2.521
## median_house_value 96.040 261200.744 13794 206855.817
## median_income 67.289 4.498 13794 3.871
## total_rooms 17.077 2818.455 13794 2635.763
## households 13.885 525.572 13794 499.540
## population 6.997 1464.335 13794 1425.477
## housing_median_age 6.092 29.015 13794 28.639
## longitude 3.235 -119.538 13794 -119.570
## latitude -31.051 35.307 13794 35.632
El modelo con K-Prototypes (3 grupos) permitió identificar perfiles socioeconómicos claramente diferenciados dentro de la base de viviendas.
El Grupo 1, que reúne el 14,8% de los casos (3.046 observaciones), corresponde a zonas de bajo ingreso y bajo valor de vivienda, con una población ligeramente superior al promedio y viviendas algo más jóvenes; este perfil refleja áreas de mayor densidad poblacional y menor desarrollo económico.
El Grupo 2, que agrupa el 18,4% de los casos (3.800 observaciones), se caracteriza por presentar los niveles más bajos tanto en ingresos como en valor de la vivienda, además de un menor número de habitaciones y hogares en comparación con el promedio, lo cual lo perfila como el segmento más precario y con condiciones habitacionales más limitadas.
Finalmente, el Grupo 3, que concentra el 66,8% de las observaciones (13.794 casos), representa a la mayoría de las viviendas y se distingue por ingresos superiores a la media, valores de vivienda más altos, mayor tamaño de hogar y un parque habitacional ligeramente más antiguo, lo que refleja un segmento consolidado con mejores condiciones socioeconómicas. En conjunto, los resultados muestran la estrecha relación entre el nivel de ingresos y el valor de la vivienda, confirmando que el algoritmo logró separar a la población en perfiles coherentes y representativos de la heterogeneidad socioeconómica de la muestra.