Celem projektu jest identyfikacja segmentów klientów na podstawie
cech demograficznych i zakupowych z wykorzystaniem metod klasteryzacji
niesuperwizowanej. W analizie porównuję rozwiązania uzyskane
metodami:
k-means (rozwiązanie bazowe),
PAM (odporniejsze na obserwacje odstające – medoidy),
klasteryzacja hierarchiczna (complete oraz Ward.D2),oraz wykonuję
elementy walidacji i praktycznego zastosowania: dobór liczby
klastrów,
porównanie metod, predykcja przynależności nowych obserwacji.
Wykorzystano zbiór danych Mall_Customers.csv, w którym jednostką
analizy jest #pojedynczy klient. Do klastrowania wybrano zmienne:
Age – wiek,
Annual Income (k$) – dochód roczny,
Spending Score (1–100) – wskaźnik intensywności wydatków.
Wykorzystane biblioteki
library(dplyr)
library(ggplot2)
library(factoextra)
library(cluster)
library(flexclust)
library(psych)
library(dendextend)
library(hopkins)
library(mclust)
ustawiam seed w celu powtarzalności wyników
set.seed(123)
Uporządkowanie nazw kolumn (bez spacji i znaków specjalnych)
mall <- read.csv("Mall_Customers.csv", stringsAsFactors = FALSE)
names(mall) <- make.names(names(mall))
glimpse(mall)
## Rows: 200
## Columns: 5
## $ CustomerID <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, …
## $ Gender <chr> "Male", "Male", "Female", "Female", "Female", "…
## $ Age <int> 19, 21, 20, 23, 31, 22, 35, 23, 64, 30, 67, 35,…
## $ Annual.Income..k.. <int> 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19,…
## $ Spending.Score..1.100. <int> 39, 81, 6, 77, 40, 76, 6, 94, 3, 72, 14, 99, 15…
summary(mall)
## CustomerID Gender Age Annual.Income..k..
## Min. : 1.00 Length:200 Min. :18.00 Min. : 15.00
## 1st Qu.: 50.75 Class :character 1st Qu.:28.75 1st Qu.: 41.50
## Median :100.50 Mode :character Median :36.00 Median : 61.50
## Mean :100.50 Mean :38.85 Mean : 60.56
## 3rd Qu.:150.25 3rd Qu.:49.00 3rd Qu.: 78.00
## Max. :200.00 Max. :70.00 Max. :137.00
## Spending.Score..1.100.
## Min. : 1.00
## 1st Qu.:34.75
## Median :50.00
## Mean :50.20
## 3rd Qu.:73.00
## Max. :99.00
W klasteryzacji opartej o odległości i centroidy konieczne jest
ujednolicenie skali zmiennych. Dlatego dane są standaryzowane (scale()),
aby żadna zmienna (np. dochód) nie dominowała wyniku.
X <- mall %>% select(Age, Annual.Income..k.., Spending.Score..1.100.)
colSums(is.na(X))
## Age Annual.Income..k.. Spending.Score..1.100.
## 0 0 0
str(X)
## 'data.frame': 200 obs. of 3 variables:
## $ Age : int 19 21 20 23 31 22 35 23 64 30 ...
## $ Annual.Income..k.. : int 15 15 16 16 17 17 18 18 19 19 ...
## $ Spending.Score..1.100.: int 39 81 6 77 40 76 6 94 3 72 ...
X_s <- scale(X)
Zanim zastosuję algorytmy klasteryzacji, sprawdzam czy dane wykazują
tendencję do tworzenia klastrów. W tym celu obliczam statystykę
Hopkinsa.
X_hopkins <- as.data.frame(X_s)
stopifnot(sum(!is.finite(as.matrix(X_hopkins))) == 0)
m <- min(50, max(10, floor(nrow(X_hopkins) / 10)))
H <- hopkins(X_hopkins, m = m)
H
## [1] 0.967832
Interpretacja: Uzyskano wartość Hopkinsa 0.967832, co wskazuje na
bardzo silną tendencję klastrową danych.
Dobór liczby klastrów
Liczbę klastrów dobieram na podstawie dwóch kryteriów:
silhouette – ocenia jakość rozdzielenia klastrów,
gap statistic – porównuje wynik do struktury losowej (bardziej
konserwatywne kryterium).
fviz_nbclust(X_s, FUNcluster = kmeans, method = "silhouette") + ggtitle("Silhouette (k-means)")

fviz_nbclust(X_s, FUNcluster = kmeans, method = "gap_stat", nboot = 50) + ggtitle("Gap statistic (k-means)")

Wyniki: silhouette sugeruje k = 8, gap statistic sugeruje k = 6. W
dalszej analizie k = 6 traktuję jako rozwiązanie główne (konserwatywne i
interpretowalne),k = 8 jako wariant porównawczy.
K-means: rozwiązanie główne (k = 6) i wariant porównawczy (k =
8)
K-means dla k = 6
km6 <- eclust(X_s, "kmeans", k = 6, hc_metric = "euclidean")

fviz_cluster( km6, geom = "point", ggtheme = theme_minimal(base_size = 12), main = "K-means clustering (k = 6)" )

fviz_silhouette(km6)
## cluster size ave.sil.width
## 1 1 13 0.22
## 2 2 57 0.37
## 3 3 22 0.56
## 4 4 22 0.31
## 5 5 40 0.54
## 6 6 46 0.26

Profilowanie klastrów (średnie wartości zmiennych w
segmentach):
mall_km6 <- mall %>% mutate(cluster_k6 = factor(km6$cluster))
mall_km6 %>% group_by(cluster_k6) %>% summarise( Age = mean(Age), Income = mean(Annual.Income..k..), Spending = mean(Spending.Score..1.100.), n = n() )
## # A tibble: 6 × 5
## cluster_k6 Age Income Spending n
## <fct> <dbl> <dbl> <dbl> <int>
## 1 1 30.5 89.5 13.4 13
## 2 2 55.5 47.9 41.9 57
## 3 3 25.3 25.7 79.4 22
## 4 4 48.3 87.5 19.6 22
## 5 5 32.9 86.1 81.5 40
## 6 6 27.7 49.6 44.3 46
K-means dla k = 8 (porównanie)
km8 <- eclust(X_s, "kmeans", k = 8, hc_metric = "euclidean")

fviz_cluster( km8, geom = "point", ggtheme = theme_minimal(base_size = 12), main = "K-means clustering (k = 8)" )

fviz_silhouette(km8)
## cluster size ave.sil.width
## 1 1 11 0.29
## 2 2 18 0.35
## 3 3 23 0.49
## 4 4 8 0.30
## 5 5 39 0.48
## 6 6 36 0.39
## 7 7 44 0.46
## 8 8 21 0.29

Profilowanie klastrów (średnie wartości zmiennych w
segmentach):
mall_km8 <- mall %>% mutate(cluster_k8 = factor(km8$cluster))
mall_km8 %>% group_by(cluster_k8) %>% summarise( Age = mean(Age), Income = mean(Annual.Income..k..), Spending = mean(Spending.Score..1.100.), n = n() )
## # A tibble: 8 × 5
## cluster_k8 Age Income Spending n
## <fct> <dbl> <dbl> <dbl> <int>
## 1 1 28 77.7 14.2 11
## 2 2 49.4 82.1 18.4 18
## 3 3 25 25.3 77.6 23
## 4 4 38.8 112. 22.6 8
## 5 5 32.7 86.5 82.1 39
## 6 6 27.2 55.9 50.1 36
## 7 7 56.3 53.7 49.4 44
## 8 8 45.5 26.3 19.4 21
Porównanie jakości k=6 vs k=8 (średnia silhouette)
mean_sil_k6 <- mean(silhouette(km6$cluster, dist(X_s))[, 3])
mean_sil_k8 <- mean(silhouette(km8$cluster, dist(X_s))[, 3])
mean_sil_k6
## [1] 0.3838699
mean_sil_k8
## [1] 0.4105837
Wniosek: k=8 ma nieco wyższą średnią silhouette (0.4105837) w
stosunku do k=6 (0.3838699), jednak ze względu na kryterium gap oraz
interpretowalność segmentów, k = 6 pozostaje rozwiązaniem głównym, a k =
8 pełni rolę porównawczą
PAM (k = 6) i porównanie z k-means
pam6 <- eclust(X_s, "pam", k = 6)

fviz_cluster( pam6, geom = "point", ggtheme = theme_minimal(base_size = 12), main = "PAM clustering (k = 6)" )

fviz_silhouette(pam6)
## cluster size ave.sil.width
## 1 1 25 0.45
## 2 2 20 0.32
## 3 3 45 0.49
## 4 4 38 0.39
## 5 5 39 0.50
## 6 6 33 0.33

mall_pam6 <- mall %>% mutate(cluster_pam6 = factor(pam6$cluster))
mall_pam6 %>% group_by(cluster_pam6) %>% summarise( Age = mean(Age), Income = mean(Annual.Income..k..), Spending = mean(Spending.Score..1.100.), n = n() )
## # A tibble: 6 × 5
## cluster_pam6 Age Income Spending n
## <fct> <dbl> <dbl> <dbl> <int>
## 1 1 25.5 25.5 75.4 25
## 2 2 46.2 26.8 18.4 20
## 3 3 56.3 54.3 49.1 45
## 4 4 26.7 57.6 47.8 38
## 5 5 32.7 86.5 82.1 39
## 6 6 41.9 88.9 17.0 33
Porównanie przypisań k-means vs PAM wykonuję przy pomocy Adjusted
Rand Index (ARI) – im bliżej 1, tym bardziej zgodne segmentacje.
comp_km_pam <- comPart(km6$cluster, pam6$cluster)
comp_km_pam
## ARI RI J FM
## 0.7428825 0.9220603 0.6535627 0.7922923
Wynik: Adjusted Rand Index wyniósł 0.74, a miary Jaccarda i
Fowlkesa–Mallowsa odpowiednio 0.65 i 0.79, co wskazuje, że obie metody
identyfikują bardzo podobne segmenty klientów.
Predykcja przynależności do klastrów (train/test)
Aby pokazać praktyczną użyteczność segmentacji, wykonuję podział na
zbiór treningowy i testowy. Następnie uczę k-means na danych
treningowych i przypisuję klaster dla nowych obserwacji.
n <- nrow(X_s)
idx_train <- sample.int(n, size = floor(0.8 * n))
X_train <- X_s[idx_train, , drop = FALSE]
X_test <- X_s[-idx_train, , drop = FALSE]
mall_train <- mall[idx_train, ]
mall_test <- mall[-idx_train, ]
k-means na zbiorze treningowym
km_train <- kmeans(X_train, centers = 6, nstart = 25)
predykcja: przypisanie nowych obserwacji do istniejących
klastrów
km_train_kcca <- as.kcca(km_train, X_train)
cluster_pred <- predict(km_train_kcca, X_test)
mall_test_pred <- mall_test %>% mutate(cluster_pred = factor(cluster_pred))
table(cluster_pred)
## cluster_pred
## 1 2 3 4 5 6
## 11 8 4 3 3 11
head(mall_test_pred)
## CustomerID Gender Age Annual.Income..k.. Spending.Score..1.100. cluster_pred
## 10 10 Female 30 19 72 5
## 15 15 Male 37 20 13 3
## 18 18 Male 20 21 66 5
## 19 19 Male 52 23 29 3
## 28 28 Male 35 28 61 5
## 33 33 Male 53 33 4 3
mall_test_pred %>% group_by(cluster_pred) %>% summarise( Age = mean(Age), Income = mean(Annual.Income..k..), Spending = mean(Spending.Score..1.100.), n = n() )
## # A tibble: 6 × 5
## cluster_pred Age Income Spending n
## <fct> <dbl> <dbl> <dbl> <int>
## 1 1 26.5 57.9 46.9 11
## 2 2 32.5 77.9 86.9 8
## 3 3 47.8 28.8 18.5 4
## 4 4 42.3 101 16 3
## 5 5 28.3 22.7 66.3 3
## 6 6 58.5 56.4 48.8 11
dodaje etykiety do train
mall_train$cluster_train <- factor(km_train$cluster)
ggplot() + geom_point( data = mall_train, aes(x = Annual.Income..k.., y = Spending.Score..1.100., color = cluster_train), alpha = 0.7 ) + geom_point( data = mall_test_pred, aes(x = Annual.Income..k.., y = Spending.Score..1.100.), shape = 1, size = 2 ) + theme_minimal(base_size = 12) + labs( title = "Predykcja przynależności nowych klientów do klastrów", subtitle = "Wypełnione: dane treningowe, puste kółka: dane testowe", color = "Cluster" )

Klasteryzacja hierarchiczna i porównanie z k-means
Klasteryzacja hierarchiczna umożliwia analizę struktury danych w
postaci dendrogramu. Porównuję dwa standardowe warianty łączenia:
complete linkage, Ward.D2 (minimalizacja wariancji
wewnątrzklastrowej)
dm <- dist(X_s, method = "euclidean")
hc_complete <- hclust(dm, method = "complete")
hc_ward <- hclust(dm, method = "ward.D2")
plot(hc_complete, main = "Klasteryzacja hierarchiczna: kompletne połączenie", cex = 0.7)

plot(hc_ward, main = "Klasteryzacja hierarchiczna: Ward.D2", cex = 0.7)

plot(hc_ward, labels = FALSE, main = "Klasteryzacja hierarchiczna: Ward.D2 (ukryte etykiety)")

Cięcie drzewa do k = 6 i silhouette
k <- 6
cl_hc_complete <- cutree(hc_complete, k = k)
cl_hc_ward <- cutree(hc_ward, k = k)
sil_complete <- silhouette(cl_hc_complete, dm)
sil_ward <- silhouette(cl_hc_ward, dm)
mean_sil_complete <- mean(sil_complete[, 3])
mean_sil_ward <- mean(sil_ward[, 3])
mean_sil_complete
## [1] 0.3745611
mean_sil_ward
## [1] 0.420117
Wynik: Ward.D2 (0.420117) ma wyższą silhouette niż complete
(0.3745611) oznacza to lepszą jakość segmentacji hierarchicznej
Wizualizacja klastrów (Ward.D2)
fviz_cluster(list(data = X_s, cluster = cl_hc_ward), ggtheme = theme_minimal(base_size = 12), main = "HC Ward.D2 clusters (k = 6)")

Porównanie HC vs k-means (ARI)
ARI_hc_ward_vs_km6 <- mclust::adjustedRandIndex(cl_hc_ward, km6$cluster)
ARI_hc_complete_vs_km6 <- mclust::adjustedRandIndex(cl_hc_complete, km6$cluster)
ARI_hc_ward_vs_km6
## [1] 0.69221
ARI_hc_complete_vs_km6
## [1] 0.8080992
Wynik: ARI (Ward.D2 vs k-means) = 0.69 oznacza to wysoką zgodność
pomiędzy klasteryzacją hierarchiczną metodą Ward.D2, a rozwiązaniem
k-means dla k = 6. ARI (complete vs k-means) = 0.81 co wskazuje na
bardzo wysoką zgodność struktur klastrów uzyskanych metodą complete
linkage oraz k-means. Metoda complete w tym przypadku lepiej odtwarza
podział uzyskany przez k-means, mimo że wcześniej charakteryzowała się
niższą wartością silhouette.
WNIOSKI
1 Dane wykazują silną tendencję klastrową (Hopkins 0.967832)
2 Dobór liczby klastrów dał konflikt kryteriów: silhouette sugerował
k=8, gap k=6. Przyjęto k=6 jako rozwiązanie główne i k=8 jako
porównawcze.
3 Segmentacja k-means (k=6) jest spójna z PAM (wysoki ARI).
4 W klasteryzacji hierarchicznej Ward.D2 osiąga lepszą silhouette,
natomiast complete ma wyższą zgodność z k-means (ARI)
5 Pokazano możliwość przypisywania nowych klientów do istniejących
segmentów (podejście train/test).