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:

  1. Sólo variables continuas → comparación entre K-medias y DBSCAN.

  2. 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

library(tidyverse)
library(cluster)
library(factoextra)
library(dbscan)
library(corrplot)
library(clustMixType)   
library(NbClust)      
library(janitor)
library(patchwork)
library(skimr)
library(RColorBrewer)
library(FactoClass)

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.

str(housing)
## 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)
str(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.