Carlos Jiménez-Gallardo Estadístico | MSc Informática Educativa Universidad de La Frontera carlos.jimenez@ufrontera.cl
Data Scientist — innovate.cl cjimenez@innovate.cl
El análisis de clúster busca identificar grupos
naturales dentro de los datos para entender mejor su estructura
y comportamiento.
Es útil para:
estas son las usadas para aplicar al ejemplo a continuación.
library(tidyverse)
library(janitor)
library(skimr)
library(GGally)
library(cluster)
library(factoextra)
library(dendextend)
library(PerformanceAnalytics)
Contexto y objetivo
El avellano europeo (Corylus avellana) se concentra en el centro-sur
de Chile.
Objetivo: segmentar huertos por condiciones
agroclimáticas/suelo/manejo para identificar perfiles
productivos.
Trabajaremos con datos simulados plausibles (no
reales).
Diseñamos variables que suelen influir en el desempeño del cultivo:
skimr::skim(d_avellanos)
| Name | d_avellanos |
| Number of rows | 200 |
| Number of columns | 17 |
| _______________________ | |
| Column type frequency: | |
| character | 4 |
| numeric | 13 |
| ________________________ | |
| Group variables | None |
Variable type: character
| skim_variable | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|
| region | 0 | 1 | 5 | 12 | 0 | 7 | 0 |
| textura | 0 | 1 | 6 | 16 | 0 | 3 | 0 |
| riego | 0 | 1 | 5 | 14 | 0 | 3 | 0 |
| cultivar | 0 | 1 | 9 | 16 | 0 | 4 | 0 |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| id_huerto | 0 | 1 | 100.50 | 57.88 | 1.00 | 50.75 | 100.50 | 150.25 | 200.00 | ▇▇▇▇▇ |
| lat | 0 | 1 | -37.19 | 1.86 | -41.64 | -38.96 | -36.72 | -35.60 | -34.04 | ▁▆▂▇▅ |
| lon | 0 | 1 | -72.03 | 0.54 | -72.97 | -72.49 | -72.05 | -71.57 | -71.02 | ▆▆▇▆▅ |
| lluvia_mm | 0 | 1 | 1203.60 | 200.66 | 635.00 | 1067.25 | 1197.50 | 1338.25 | 1786.00 | ▁▅▇▅▁ |
| gdd | 0 | 1 | 1690.32 | 150.30 | 1223.00 | 1586.50 | 1705.50 | 1797.25 | 2017.00 | ▁▂▇▇▃ |
| ph | 0 | 1 | 6.42 | 0.37 | 5.25 | 6.21 | 6.43 | 6.65 | 7.48 | ▁▂▇▃▁ |
| materia_organica | 0 | 1 | 3.93 | 0.84 | 1.78 | 3.42 | 3.87 | 4.48 | 6.25 | ▂▅▇▅▁ |
| edad | 0 | 1 | 9.32 | 3.48 | 2.00 | 7.00 | 9.00 | 11.00 | 18.00 | ▃▇▇▃▂ |
| densidad | 0 | 1 | 602.63 | 124.40 | 316.00 | 510.50 | 608.50 | 689.50 | 935.00 | ▂▆▇▅▁ |
| incidencia_enf | 0 | 1 | 17.37 | 4.76 | 3.10 | 14.51 | 17.47 | 20.57 | 32.09 | ▁▅▇▅▁ |
| diam_mm | 0 | 1 | 19.01 | 1.16 | 16.62 | 18.08 | 18.96 | 19.75 | 22.64 | ▃▇▆▃▁ |
| pct_almendra | 0 | 1 | 45.32 | 3.58 | 35.86 | 42.91 | 45.40 | 47.89 | 56.03 | ▂▅▇▅▁ |
| rendimiento_kg_ha | 0 | 1 | 989.74 | 318.08 | 300.00 | 791.75 | 1017.50 | 1226.00 | 1697.00 | ▃▃▇▆▂ |
nums <- d_avellanos %>%
select(lluvia_mm, gdd, ph, materia_organica, edad, densidad,
incidencia_enf, diam_mm, pct_almendra, rendimiento_kg_ha)
chart.Correlation(nums)
Comentario: revisar las relaciones esperadas es
esencial para no llevarse sorpresas de resultados no esperados
(lluvia–sanidad, rendimiento con edad/pH/sanidad, etc.).
Si aparecen outliers severos, considerar transformar (p. ej.
log1p) o tratar extremos antes de “procesar conglomerados
(clusters)”.
Se busca que todas las variables numéricas contribuyan de forma comparable al cálculo de distancias, ya que k-means se basa en distancias (normalmente euclidianas).
¿Por qué es necesaria esta preparación?
Las variables numéricas pueden estar en escalas muy distintas (ej.: edad vs. ingresos).
Sin estandarizar, las variables con valores más grandes dominan el clustering.
k-means no maneja bien variables categóricas ni escalas heterogéneas.
¿Qué implica la preparación?
Estandarizar las variables
Resultado: media 0, desviación estándar 1.
Eliminar outliers (opcional pero recomendado)
k-means es sensible a valores extremos.
Verificar correlaciones
Variables muy correlacionadas pueden sesgar los centroides.
vars_num <- c("lluvia_mm","gdd","ph","materia_organica","edad","densidad",
"incidencia_enf","diam_mm","pct_almendra","rendimiento_kg_ha")
x_num <- d_avellanos %>%
select(all_of(vars_num)) %>%
scale() %>%
as.data.frame()
rownames(x_num) <- d_avellanos$id_huerto
colMeans(x_num) ## debería tender a 0
## lluvia_mm gdd ph materia_organica
## -1.344823e-16 4.191092e-16 7.761543e-16 -2.064278e-16
## edad densidad incidencia_enf diam_mm
## -9.166279e-17 3.608225e-17 -3.186687e-17 -9.478616e-16
## pct_almendra rendimiento_kg_ha
## 8.036974e-16 -3.112094e-17
La elección de k en k-means consiste en determinar cuántos clusters representan mejor la estructura de los datos sin sobreajustar ni simplificar en exceso.
Métodos más usados para elegir k
Se calcula la inercia (suma de distancias cuadradas al centroide) para distintos valores de k.
Se grafica k vs. inercia.
Se elige el k donde la reducción de inercia deja de ser significativa (el “codo”).
Intuición: agregar más clusters ya no mejora mucho el modelo.
Mide qué tan bien está cada punto dentro de su cluster.
Valores entre −1 y 1.
Cerca de 1, implica un buen clustering
Cerca de 0 hay clusters superpuestos
Negativo, mala asignación
Se elige el k con mayor silueta promedio.
Muy útil cuando el “codo” no es claro.
Compara la dispersión real con la esperada bajo una distribución aleatoria.
Se elige el k donde la diferencia (gap) es mayor.
Más robusto, pero computacionalmente más costoso.
A veces k se fija por:
Clustering no es solo matemático: el contexto importa.
set.seed(123)
p_wss <- fviz_nbclust(x_num, kmeans, method = "wss") +
ggtitle("Método del codo (WSS)")
p_sil <- fviz_nbclust(x_num, kmeans, method = "silhouette") +
ggtitle("Silhouette promedio")
print(p_wss)
print(p_sil)
ks <- 2:8
sil_tab_km <- sapply(ks, function(k){
fit <- kmeans(x_num, centers = k, nstart = 50)
mean(cluster::silhouette(fit$cluster, dist(x_num))[, "sil_width"])
})
tibble(k = ks, silhouette_prom = sil_tab_km)
## # A tibble: 7 × 2
## k silhouette_prom
## <int> <dbl>
## 1 2 0.120
## 2 3 0.113
## 3 4 0.110
## 4 5 0.105
## 5 6 0.108
## 6 7 0.107
## 7 8 0.105
set.seed(123)
gap_km <- clusGap(x_num, FUN = kmeans, K.max = 8, B = 30)
fviz_gap_stat(gap_km) + ggtitle("Gap statistic")
print(gap_km, method = "firstmax")
## Clustering Gap statistic ["clusGap"] from call:
## clusGap(x = x_num, FUNcluster = kmeans, K.max = 8, B = 30)
## B=30 simulated reference sets, k = 1..8; spaceH0="scaledPCA"
## --> Number of clusters (method 'firstmax'): 1
## logW E.logW gap SE.sim
## [1,] 5.375059 5.829316 0.4542572 0.01387554
## [2,] 5.300956 5.745565 0.4446090 0.01378458
## [3,] 5.253893 5.698228 0.4443347 0.01257054
## [4,] 5.208399 5.659791 0.4513921 0.01147519
## [5,] 5.180377 5.629514 0.4491371 0.01245971
## [6,] 5.157442 5.601219 0.4437774 0.01398229
## [7,] 5.134679 5.575426 0.4407471 0.01461328
## [8,] 5.107922 5.552020 0.4440981 0.01313016
Interpretación mínima (para el informe): - Silhouette bajo (≈ 0.10–0.25) sugiere estructura débil o continuo. - Gap con k=1 indica ausencia de evidencia fuerte de agrupación clara. - En ese caso, k se elige por interpretabilidad y uso posterior.
Para continuar, tomaremos
k = 3por interpretabilidad (ajusta según tus gráficos/datos).
k_elegido <- 3
set.seed(123)
km <- kmeans(x_num, centers = k_elegido, nstart = 50)
d_avellanos_km <- d_avellanos %>%
mutate(cluster_km = factor(km$cluster))
table(d_avellanos_km$cluster_km)
##
## 1 2 3
## 65 82 53
Cluster 1 tiene 65 observaciones, mientras que C2 tiene 82 y C3 tiene 53
Los tamaños son relativamente balanceados (no hay clusters diminutos).
sil_km <- silhouette(km$cluster, dist(x_num))
fviz_silhouette(sil_km) + ggtitle("Silhouette por observación (k-means)")
## cluster size ave.sil.width
## 1 1 65 0.15
## 2 2 82 0.10
## 3 3 53 0.09
Silhouette mide qué tan bien está cada observación dentro de su cluster.
Usa distancias entre observaciones (dist(x_num)).
El valor va de -1 a 1:
Los valores promedio de silhouette son bajos: - Cluster 1: 0.15 - Cluster 2: 0.10 - Cluster 3: 0.09
Esto indica que:
Los clusters no están muy bien separados
Hay bastante solapamiento entre grupos
La estructura existe, pero es débil
pca <- prcomp(x_num, center = TRUE, scale. = TRUE)
fviz_pca_biplot(
pca, geom.ind = "point",
habillage = d_avellanos_km$cluster_km,
addEllipses = TRUE
) + ggtitle("PCA (k-means)")
EXPLICACION
El PCA, reduce las variables originales a dos dimensiones: Dim1 con un 21.3% y Dim2 con 15.2% de aporte de información. Esto implica, que en conjunto explican 36.5% de la variabilidad.
Puntos: parcelas/observaciones.
Está dominada por: lluvia_mm, incidencia_enf️, materia_organic.
De acuerdo con esto, la Dim1 podría representar una gradiente ambiental-productivo, explicándonse como a la izquierda, condiciones más bajas / menos favorables, mientras que a la derecha, mayor lluvia, mayor MO y mayor incidencia de enfermedades.
Está asociada a: rendimiento, edad, productividad, reflejando un gradiente estructural/productiva, relacionado con: edad del cultivo, densidad, desempeño productivo
Cluster 1 (rojo,Derecha del gráfico)
Características:
Posible Interpretación: Parcelas en ambientes más húmedos y fértiles, pero con mayor presión sanitaria.
Cluster 2 (verde, Zona central-superior)
Características:
Posible Interpretación: Parcelas más maduras y estructuralmente estables, con mejor desempeño productivo relativo.
Nota: Este es el cluster más “productivo” según el PCA.
Cluster 3 (azul, Inferior-izquierda)
Características:
Posible Interpretación:
Parcelas más jóvenes o en ambientes térmicos/secos, con menor productividad.
Las elipses se solapan bastante, especialmente entre verde y rojo. Esto coincide con los valores bajos de silhouette que obtuviste antes (≈ 0.10–0.15).
Conclusión:
Hay tendencias claras, pero no fronteras nítidas. El sistema es continuo, no discretamente agrupado.
las variables lluvia_mm, materia_organica e incidencia_enf están correlacionadas entre sí, mientras que rendimiento se asocia más con edad y densidad que con lluvia y pH y gdd apuntan en direcciones opuestas a lluvia.
Nota: El rendimiento parece depender más de la estructura del cultivo que solo del ambiente.
Conclusión
El PCA muestra que los clusters obtenidos por k-means representan gradientes ambientales y productivos, más que grupos completamente separados. El cluster verde se asocia a mayores rendimientos y parcelas más maduras, mientras que el cluster rojo refleja ambientes húmedos con mayor incidencia de enfermedades y el azul condiciones más limitantes.
perfil_raw <- d_avellanos_km %>%
group_by(cluster_km) %>%
summarise(across(all_of(vars_num), mean), .groups = "drop")
perfil_std <- as_tibble(x_num) %>%
mutate(cluster_km = d_avellanos_km$cluster_km) %>%
group_by(cluster_km) %>%
summarise(across(everything(), mean), .groups = "drop")
perfil_raw
## # A tibble: 3 × 11
## cluster_km lluvia_mm gdd ph materia_organica edad densidad
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 1 1384. 1572. 6.32 4.36 8.83 632.
## 2 2 1104. 1715. 6.47 3.51 11.4 594.
## 3 3 1137. 1797. 6.47 4.06 6.74 580.
## # ℹ 4 more variables: incidencia_enf <dbl>, diam_mm <dbl>, pct_almendra <dbl>,
## # rendimiento_kg_ha <dbl>
perfil_std
## # A tibble: 3 × 11
## cluster_km lluvia_mm gdd ph materia_organica edad densidad
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 1 0.897 -0.785 -0.270 0.513 -0.140 0.238
## 2 2 -0.498 0.165 0.129 -0.508 0.591 -0.0721
## 3 3 -0.331 0.708 0.132 0.157 -0.742 -0.180
## # ℹ 4 more variables: incidencia_enf <dbl>, diam_mm <dbl>, pct_almendra <dbl>,
## # rendimiento_kg_ha <dbl>
perfil_std_long <- perfil_std %>%
pivot_longer(-cluster_km, names_to = "variable", values_to = "media_std")
ggplot(
perfil_std_long,
aes(
x = reorder(variable, media_std),
y = media_std,
group = cluster_km,
color = cluster_km
)
) +
geom_hline(yintercept = 0, linewidth = 0.4, color = "grey40") +
geom_line(linewidth = 1) +
geom_point(size = 2) +
coord_flip() +
labs(
title = "Perfil estandarizado por clúster (k-means)",
x = NULL,
y = "Media (z)",
color = "Clúster"
) +
theme_minimal()
Cómo leer el gráfico
El eje X (Media Z) muestra valores estandarizados:
Nota: Cuanto más lejos de 0, más característica es esa variable para el clúster.
Variables que realmente separan los clústeres
Las que muestran mayor dispersión (más informativas):
Otras (menos discriminantes):
Para incluir variables categóricas sin dummies, usamos distancia Gower y PAM (medoides).
vars_mixtas <- c("lluvia_mm","gdd","ph","materia_organica","edad","densidad",
"incidencia_enf","diam_mm","pct_almendra",
"textura","riego","cultivar","region")
datos_mixtos <- d_avellanos %>%
select(all_of(vars_mixtas)) %>%
mutate(across(where(is.character), as.factor))
gower_dist <- daisy(datos_mixtos, metric = "gower")
pam_fit <- pam(gower_dist, k = k_elegido)
d_avellanos_pam <- d_avellanos %>%
mutate(cluster_pam = factor(pam_fit$clustering))
sil_pam <- silhouette(pam_fit)
fviz_silhouette(sil_pam) + ggtitle("Silhouette (PAM + Gower)")
## cluster size ave.sil.width
## 1 1 91 0.12
## 2 2 55 0.09
## 3 3 54 0.09
perfil_pam_num <- d_avellanos_pam %>%
group_by(cluster_pam) %>%
summarise(across(all_of(vars_num), mean), .groups = "drop")
perfil_pam_cat <- d_avellanos_pam %>%
group_by(cluster_pam) %>%
summarise(
n = n(),
across(c(riego, textura, cultivar, region),
~ names(sort(table(.), decreasing = TRUE))[1],
.names = "modo_{.col}"),
.groups = "drop"
)
perfil_pam_num
## # A tibble: 3 × 11
## cluster_pam lluvia_mm gdd ph materia_organica edad densidad
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 1 1267. 1652. 6.45 3.99 9.03 615.
## 2 2 1199. 1719. 6.49 3.95 9.95 621.
## 3 3 1102. 1725. 6.32 3.82 9.17 564.
## # ℹ 4 more variables: incidencia_enf <dbl>, diam_mm <dbl>, pct_almendra <dbl>,
## # rendimiento_kg_ha <dbl>
perfil_pam_cat
## # A tibble: 3 × 6
## cluster_pam n modo_riego modo_textura modo_cultivar modo_region
## <fct> <int> <chr> <chr> <chr> <chr>
## 1 1 91 Goteo Franco Tonda di Giffoni La Araucanía
## 2 2 55 Secano Franco Barcelona Maule
## 3 3 54 Goteo Franco-arcilloso Tonda di Giffoni Ñuble
comp <- tibble(
id_huerto = d_avellanos$id_huerto,
km = d_avellanos_km$cluster_km,
pam = d_avellanos_pam$cluster_pam
)
tab_comp <- table(comp$km, comp$pam)
tab_comp
##
## 1 2 3
## 1 42 14 9
## 2 34 22 26
## 3 15 19 19
acuerdo_simple <- sum(diag(tab_comp)) / sum(tab_comp)
acuerdo_simple
## [1] 0.415
ggplot(d_avellanos_km, aes(x = cluster_km, y = rendimiento_kg_ha)) +
geom_boxplot() +
labs(x = "Cluster (k-means)", y = "Rendimiento (kg/ha)", title = "Rendimiento por clúster")
d_avellanos_km %>%
pivot_longer(c(lluvia_mm, gdd), names_to = "variable", values_to = "valor") %>%
ggplot(aes(x = cluster_km, y = valor)) +
geom_boxplot() +
facet_wrap(~ variable, scales = "free_y") +
labs(x = "Cluster (k-means)", y = NULL, title = "Clima por clúster")
Ajusta usando perfil_raw / perfil_pam_num /
perfil_pam_cat.