# install.packages("gifski")      # untuk membuat GIF
library(gifski)
## Warning: package 'gifski' was built under R version 4.4.3
# GIF disimpan di folder working directory
OUT_DIR <- "hasil_gif"
dir.create(OUT_DIR, showWarnings = FALSE)
cat("GIF disimpan di:", normalizePath(OUT_DIR), "\n")
## GIF disimpan di: E:\Kampus\SMT5\Pembelajaran Mesin Statistika\Presentasi_grid based & mode seeking clustering\hasil_gif
# 1. Dataset dan preprocessing
# Data Gaussian Blobs 
set.seed(42)                                 

n_per_kelompok <- c(334, 333, 333)           
pusat_asli <- rbind(c( 4.7,  2.0),            
                    c(-2.6,  8.9),          
                    c(-6.8, -6.8))           

X <- do.call(rbind, lapply(1:3, function(k) {
  cbind(rnorm(n_per_kelompok[k], mean = pusat_asli[k, 1], sd = 1),   
        rnorm(n_per_kelompok[k], mean = pusat_asli[k, 2], sd = 1))   
}))
colnames(X) <- c("Fitur1", "Fitur2")          
y_asli <- rep(1:3, times = n_per_kelompok)   
warna_kelompok <- hsv(h = c(0, 1/3, 2/3), s = 0.45, v = 1)   

# Ukuran dan struktur dataset
cat("\n=== 1.1 Ukuran dan struktur ===\n")
## 
## === 1.1 Ukuran dan struktur ===
cat("Dimensi (baris x kolom):", dim(X), "\n")
## Dimensi (baris x kolom): 1000 2
cat("Kelas objek:", class(X), "| tipe data:", typeof(X), "\n")
## Kelas objek: matrix array | tipe data: double
str(X)
##  num [1:1000, 1:2] 6.07 4.14 5.06 5.33 5.1 ...
##  - attr(*, "dimnames")=List of 2
##   ..$ : NULL
##   ..$ : chr [1:2] "Fitur1" "Fitur2"
print(head(X))
##        Fitur1    Fitur2
## [1,] 6.070958 2.9885968
## [2,] 4.135302 1.9265417
## [3,] 5.063128 0.6129734
## [4,] 5.332863 0.6933241
## [5,] 5.104268 1.2316047
## [6,] 4.593875 1.4728919
print(table(Kelompok_Asli = y_asli))
## Kelompok_Asli
##   1   2   3 
## 334 333 333
# 1.2 Eksplorasi sederhana (EDA) 
cat("\n=== 1.2 EDA ===\n")
## 
## === 1.2 EDA ===
print(summary(X))
##      Fitur1           Fitur2      
##  Min.   :-9.414   Min.   :-9.150  
##  1st Qu.:-6.137   1st Qu.:-6.087  
##  Median :-2.608   Median : 1.983  
##  Mean   :-1.581   Mean   : 1.357  
##  3rd Qu.: 4.045   3rd Qu.: 8.255  
##  Max.   : 7.402   Max.   :11.534
cat("\nSimpangan baku:\n"); print(round(apply(X, 2, sd), 3))
## 
## Simpangan baku:
## Fitur1 Fitur2 
##  4.851  6.501
cat("\nRentang (maks - min):\n"); print(round(apply(X, 2, function(v) diff(range(v))), 3))
## 
## Rentang (maks - min):
## Fitur1 Fitur2 
## 16.816 20.684
cat("\nKorelasi antarfitur:\n"); print(round(cor(X), 3))
## 
## Korelasi antarfitur:
##        Fitur1 Fitur2
## Fitur1  1.000  0.409
## Fitur2  0.409  1.000
# Rata-rata dan simpangan baku per kelompok
ringkas_kelompok <- aggregate(as.data.frame(X), by = list(Kelompok = y_asli),
                              FUN = function(v) c(mean = mean(v), sd = sd(v)))
cat("\nRata-rata dan sd per kelompok asli:\n"); print(ringkas_kelompok)
## 
## Rata-rata dan sd per kelompok asli:
##   Kelompok Fitur1.mean  Fitur1.sd Fitur2.mean  Fitur2.sd
## 1        1   4.6777384  0.9629144   1.9885700  1.0163206
## 2        2  -2.6367759  1.0365894   8.8876647  0.9848974
## 3        3  -6.8022651  0.9626058  -6.8083516  1.0063183
# Visualisasi: 
par(mfrow = c(1, 3), mar = c(4, 4, 3, 1))
hist(X[, 1], breaks = 30, col = "lightblue", border = "white",
     main = "Histogram Fitur 1", xlab = "Fitur 1")
hist(X[, 2], breaks = 30, col = "lightblue", border = "white",
     main = "Histogram Fitur 2", xlab = "Fitur 2")
boxplot(X, col = "lightblue", main = "Boxplot kedua fitur")

par(mfrow = c(1, 1), mar = c(5, 4, 4, 2) + 0.1)

plot(X[, 1], X[, 2], pch = 21, bg = warna_kelompok[y_asli], col = "gray40", cex = 1.1,
     xlab = "Fitur 1", ylab = "Fitur 2", main = "Scatter plot Gaussian Blobs")
grid(col = "gray90")
legend("topright", legend = paste("Kelompok", 1:3), pch = 21, pt.bg = warna_kelompok,
       col = "gray40", bty = "n")

# 1.3 Missing values, duplikat, dan nilai tak hingga 
cat("\n=== 1.3 Pemeriksaan data ===\n")
## 
## === 1.3 Pemeriksaan data ===
cat("Missing value per kolom:\n"); print(colSums(is.na(X)))
## Missing value per kolom:
## Fitur1 Fitur2 
##      0      0
cat("Total missing value :", sum(is.na(X)), "\n")
## Total missing value : 0
cat("Nilai tak hingga    :", sum(!is.finite(X[!is.na(X)])), "\n")
## Nilai tak hingga    : 0
cat("Baris duplikat      :", sum(duplicated(X)), "\n")
## Baris duplikat      : 0
# 1.4 Distribusi: kemencengan, normalitas, pencilan
cat("\n=== 1.4 Distribusi ===\n")
## 
## === 1.4 Distribusi ===
kemencengan <- function(v) mean((v - mean(v))^3) / mean((v - mean(v))^2)^1.5
cat("Kemencengan (skewness):", round(apply(X, 2, kemencengan), 3), "\n")
## Kemencengan (skewness): 0.298 -0.143
# Shapiro-Wilk: data gabungan vs per kelompok
cat("Shapiro-Wilk data gabungan, p-value:",
    signif(apply(X, 2, function(v) shapiro.test(v)$p.value), 3), "\n")
## Shapiro-Wilk data gabungan, p-value: 7.8e-26 1.8e-26
p_kelompok <- sapply(1:3, function(k) apply(X[y_asli == k, ], 2,
                                            function(v) shapiro.test(v)$p.value))
colnames(p_kelompok) <- paste("Kelompok", 1:3)
cat("Shapiro-Wilk per kelompok, p-value:\n"); print(round(p_kelompok, 3))
## Shapiro-Wilk per kelompok, p-value:
##        Kelompok 1 Kelompok 2 Kelompok 3
## Fitur1      0.921      0.533      0.612
## Fitur2      0.732      0.913      0.412
# Pencilan: aturan 1,5 x IQR
jumlah_pencilan <- apply(X, 2, function(v) {
  q <- quantile(v, c(0.25, 0.75)); iqr <- q[2] - q[1]
  sum(v < q[1] - 1.5 * iqr | v > q[2] + 1.5 * iqr)
})
cat("Pencilan (aturan 1,5 x IQR):", jumlah_pencilan, "\n")
## Pencilan (aturan 1,5 x IQR): 0 0
# 1.5 Skala fitur
cat("\n=== 1.5 Skala fitur ===\n")
## 
## === 1.5 Skala fitur ===
rasio_sd <- sd(X[, 1]) / sd(X[, 2])
cat("Rasio sd Fitur1 / Fitur2:", round(rasio_sd, 3), "\n")
## Rasio sd Fitur1 / Fitur2: 0.746
# 1.6 Preprocessing
cat("\n=== 1.6 Preprocessing ===\n")
## 
## === 1.6 Preprocessing ===
# (a) Pembersihan: buang baris tidak lengkap
lengkap <- complete.cases(X)
X       <- X[lengkap, , drop = FALSE]
y_asli  <- y_asli[lengkap]
cat("Baris dibuang karena missing value:", sum(!lengkap), "\n")
## Baris dibuang karena missing value: 0
# (b) Skala: Mean Shift memakai satu bandwidth h untuk semua dimensi (jarak Euclid),
if (rasio_sd > 0.5 && rasio_sd < 2) {
  cat("Skala sebanding -> standarisasi tidak diperlukan, data dipakai apa adanya\n")
} else {
  cat("Skala berbeda mencolok -> pertimbangkan X <- scale(X) (h dan batas plot ikut disesuaikan)\n")
}
## Skala sebanding -> standarisasi tidak diperlukan, data dipakai apa adanya
# (c) Format: matriks numerik, baris = titik, kolom = fitur
stopifnot(is.matrix(X), is.numeric(X))
n <- nrow(X)
d <- ncol(X)
cat("Data siap dipakai: n =", n, "| d =", d, "\n")
## Data siap dipakai: n = 1000 | d = 2
# 2. rumus
# Rumus 7.1: estimasi kepadatan kernel 
#     f_K(x) = 1/(n h^d) * sum K((x - x_i)/h)

K_gauss <- function(U) {
  # Kernel Gaussian ternormalisasi: (2*pi)^(-d/2) * exp(-|u|^2 / 2)
  if (is.null(dim(U))) U <- matrix(U, nrow = 1)
  dim_u <- ncol(U)
  (2 * pi)^(-dim_u / 2) * exp(-0.5 * rowSums(U^2))
}

f_K <- function(x, data, h) {
  U <- sweep(data, 2, x) / h
  sum(K_gauss(U)) / (nrow(data) * h^ncol(data))
}

h0 <- 1.9
x0 <- colMeans(X)                           
rumus_71 <- f_K(x0, X, h0)
pembanding <- mean(dnorm(x0[1], X[, 1], h0) * dnorm(x0[2], X[, 2], h0))
cat("\nx0 =", round(x0, 4), "\n")
## 
## x0 = -1.5808 1.3566
cat(sprintf("f_K(x0) rumus 7.1 = %.8f\n", rumus_71))
## f_K(x0) rumus 7.1 = 0.00017042
cat(sprintf("f(x0) pembanding  = %.8f\n", pembanding))
## f(x0) pembanding  = 0.00017042
cat("sama:", isTRUE(all.equal(rumus_71, pembanding)), "\n")
## sama: TRUE
# syarat kernel dan kernel Gaussian
# kernel Gaussian sebagai 1/(2*pi^(d/2)) * exp(-|x|^2/2).
# Bentuk ternormalisasi untuk semua dimensi: 1/(2*pi)^(d/2) * exp(-|x|^2/2).

K_buku <- function(U) {
  if (is.null(dim(U))) U <- matrix(U, nrow = 1)
  dim_u <- ncol(U)
  1 / (2 * pi^(dim_u / 2)) * exp(-0.5 * rowSums(U^2))
}

# Syarat integral K = 1 (jumlah Riemann pada grid)
g1 <- matrix(seq(-10, 10, length.out = 4001), ncol = 1)
a  <- seq(-8, 8, length.out = 801)
g2 <- as.matrix(expand.grid(a, a))
dx <- g1[2, 1] - g1[1, 1]
da <- a[2] - a[1]

hasil72 <- data.frame(
  dimensi = c("d = 1", "d = 2", "d = 3 (analitik)"),
  integral_K_gauss = c(sum(K_gauss(g1)) * dx,
                       sum(K_gauss(g2)) * da^2,
                       1),
  integral_K_buku  = c(sum(K_buku(g1)) * dx,
                       sum(K_buku(g2)) * da^2,
                       2^(3 / 2 - 1))
)
cat("\nSyarat 7.2 (integral kernel):\n")
## 
## Syarat 7.2 (integral kernel):
print(format(hasil72, digits = 4), row.names = FALSE)
##           dimensi integral_K_gauss integral_K_buku
##             d = 1                1          0.7071
##             d = 2                1          1.0000
##  d = 3 (analitik)                1          1.4142
# Syarat K(x) = K(|x|), nilai kernel hanya bergantung pada jarak dari pusat
x <- c(1.3, -0.7)
t_putar <- 40 * pi / 180
R_putar <- matrix(c(cos(t_putar),  sin(t_putar),
                    -sin(t_putar), cos(t_putar)), nrow = 2)   
cat("\nK(x)               =", K_gauss(x), "\n")
## 
## K(x)               = 0.05351052
cat("K(-x)              =", K_gauss(-x), "\n")
## K(-x)              = 0.05351052
cat("K(Rx) dgn |Rx|=|x| =", K_gauss(as.numeric(R_putar %*% x)), "\n")
## K(Rx) dgn |Rx|=|x| = 0.05351052
cat("radial:", isTRUE(all.equal(c(K_gauss(-x), K_gauss(as.numeric(R_putar %*% x))),
                                rep(K_gauss(x), 2))), "\n")
## radial: TRUE
# Rumus: gradien dan vektor pergeseran ---------
# kernel Gaussian K(x) = k(|x|^2)
c_k <- (2 * pi)^(-d / 2)
x0  <- c(1, 1)
h0  <- 1.9

s <- rowSums((sweep(X, 2, x0) / h0)^2)       
g <- 0.5 * c_k * exp(-s / 2)                  

# Rumus vektor pergeseran m(x)
m <- colSums(g * X) / sum(g) - x0
# Rumus gradien dari rumus
grad_rumus <- 2 / (n * h0^(d + 2)) * sum(g) * m
# Gradien numerik dari f_K 
eps <- 1e-5
grad_num <- sapply(1:d, function(j) {
  e <- numeric(d); e[j] <- eps
  (f_K(x0 + e, X, h0) - f_K(x0 - e, X, h0)) / (2 * eps)
})

kosinus <- sum(grad_rumus * grad_num) /
  (sqrt(sum(grad_rumus^2)) * sqrt(sum(grad_num^2)))
cat("\nm(x0)                  =", round(m, 6), "\n")
## 
## m(x0)                  = 2.936148 0.821226
cat("gradien dari rumus 7.5 =", grad_rumus, "\n")
## gradien dari rumus 7.5 = 0.001886652 0.0005276867
cat("gradien numerik        =", grad_num, "\n")
## gradien numerik        = 0.001886652 0.0005276867
cat(sprintf("kemiripan arah (cosine) = %.8f\n", kosinus))
## kemiripan arah (cosine) = 1.00000000
cat("sama:", isTRUE(all.equal(as.numeric(grad_rumus), as.numeric(grad_num), tolerance = 1e-4)), "\n")
## sama: TRUE
# Pembaruan: x_baru = x + m(x)
x_baru <- x0 + m
w      <- exp(-rowSums(sweep(X, 2, x0)^2) / (2 * h0^2))
x_kode <- colSums(w * X) / sum(w)
cat("\nx_baru = x + m(x)       =", round(x_baru, 6), "\n")
## 
## x_baru = x + m(x)       = 3.936148 1.821226
cat("rata-rata berbobot kode =", round(x_kode, 6), "\n")
## rata-rata berbobot kode = 3.936148 1.821226
cat("sama:", isTRUE(all.equal(x_baru, x_kode)), "\n")
## sama: TRUE
# 3. Mean Shift kernel Gaussian
mean_shift_riwayat <- function(data, h, max_iter = 150, tol = 1e-4) {
  P <- data
  riwayat <- list(P)                          # riwayat[[1]] = iterasi ke-0
  for (it in 1:max_iter) {
    d2 <- outer(P[, 1], data[, 1], "-")^2 + outer(P[, 2], data[, 2], "-")^2
    w  <- exp(-d2 / (2 * h^2))
    P_baru <- (w %*% data) / rowSums(w)
    geser  <- max(abs(P_baru - P))
    P <- P_baru
    riwayat[[length(riwayat) + 1]] <- P
    if (geser < tol) break
  }
  riwayat
}

# 4. Persiapan animasi untuk satu nilai h
batas_x <- c(-11, 8.5)
batas_y <- c(-12, 14)

siapkan_animasi <- function(h, max_iter = 150) {
  riwayat_asli <- mean_shift_riwayat(X, h, max_iter = max_iter)
  akhir   <- riwayat_asli[[length(riwayat_asli)]]
  iterasi <- length(riwayat_asli) - 1
  
  # frame antara agar gerakan halus bila konvergen cepat
  sub <- if (length(riwayat_asli) < 20) 4 else 1
  riwayat <- list()
  for (i in 1:(length(riwayat_asli) - 1)) {
    a_ <- riwayat_asli[[i]]; b_ <- riwayat_asli[[i + 1]]
    for (j in 0:(sub - 1)) riwayat[[length(riwayat) + 1]] <- a_ + (b_ - a_) * j / sub
  }
  riwayat[[length(riwayat) + 1]] <- akhir
  nf <- length(riwayat)
  
  # label cluster: single linkage, dipotong pada jarak 0.2
  label <- cutree(hclust(dist(akhir), method = "single"), h = 0.2)
  k <- max(label)
  
  # warna
  hue   <- seq(0, 1, length.out = k + 1)[-(k + 1)]
  pastel <- hsv(h = hue, s = 0.45, v = 1)
  tua    <- hsv(h = hue, s = 0.85, v = 0.8)
  warna  <- pastel[label]
  pusat  <- t(sapply(1:k, function(cl) colMeans(akhir[label == cl, , drop = FALSE])))
  if (k == 1) pusat <- matrix(pusat, nrow = 1)
  colnames(pusat) <- c("Fitur1", "Fitur2")
  
  # permukaan kepadata
  gx <- seq(batas_x[1], batas_x[2], length.out = 130)
  gy <- seq(batas_y[1], batas_y[2], length.out = 130)
  ex <- exp(-outer(gx, X[, 1], "-")^2 / (2 * h^2))
  ey <- exp(-outer(gy, X[, 2], "-")^2 / (2 * h^2))
  kde <- ex %*% t(ey)                         # baris = gx, kolom = gy
  
  # lintasan setiap titik ke-12
  trail_idx <- seq(1, n, by = 12)
  jx <- sapply(riwayat, function(P) P[trail_idx, 1])
  jy <- sapply(riwayat, function(P) P[trail_idx, 2])
  
  # frame yang ditampilkan
  langkah <- max(1, ceiling(nf / 45))
  frames  <- sort(unique(c(seq(0, nf - 1, by = langkah), nf - 1)))
  frames  <- c(frames, rep(nf - 1, 10))
  
  list(h = h, riwayat = riwayat, nf = nf, sub = sub, iterasi = iterasi,
       k = k, warna = warna, pusat = pusat, tua = tua,
       gx = gx, gy = gy, kde = kde, jx = jx, jy = jy, frames = frames)
}

# Menggambar satu frame
gambar_frame <- function(ob, f) {
  par(mar = c(4.2, 4.2, 3.6, 1))
  terakhir <- (f == ob$nf - 1)
  judul <- paste0("Mean Shift (kernel Gaussian), h = ", ob$h,
                  "\nIterasi ke-", min(f %/% ob$sub, ob$iterasi),
                  if (terakhir) paste0("  |  ", ob$k, " cluster") else "")
  plot(NA, xlim = batas_x, ylim = batas_y, xlab = "Fitur 1", ylab = "Fitur 2",
       main = judul, font.main = 2, cex.main = 1)
  
  # kontur kepadatan
  tingkat <- seq(min(ob$kde), max(ob$kde), length.out = 15)
  .filled.contour(ob$gx, ob$gy, ob$kde, levels = tingkat,
                  col = adjustcolor(colorRampPalette(c("#f7fbff", "#08306b"))(14),
                                    alpha.f = 0.35))
  contour(ob$gx, ob$gy, ob$kde, levels = tingkat, add = TRUE, drawlabels = FALSE,
          col = adjustcolor("white", 0.6), lwd = 0.4)
  
  # data asli (diam) dan lintasan
  points(X[, 1], X[, 2], pch = 16, cex = 0.5, col = "#bdbdbd")
  if (f >= 1) {
    matlines(t(ob$jx[, 1:(f + 1)]), t(ob$jy[, 1:(f + 1)]),
             lty = 1, lwd = 0.6, col = adjustcolor("gray40", 0.55))
  }
  
  # titik yang bergeser
  P <- ob$riwayat[[f + 1]]
  points(P[, 1], P[, 2], pch = 21, bg = ob$warna, col = "#555555", cex = 0.9, lwd = 0.5)
  
  isi_legend <- c("Data asli (diam)", "Titik yang bergeser")
  pch_l <- c(16, 21); bg_l <- c(NA, "gray85"); col_l <- c("#bdbdbd", "#555555")
  if (terakhir) {
    points(ob$pusat[, 1], ob$pusat[, 2], pch = 23, bg = ob$tua, col = "black",
           cex = 2.2, lwd = 1.4)
    isi_legend <- c(isi_legend, "Pusat cluster")
    pch_l <- c(pch_l, 23); bg_l <- c(bg_l, "gray50"); col_l <- c(col_l, "black")
  }
  legend("topright", legend = isi_legend, pch = pch_l, pt.bg = bg_l, col = col_l,
         cex = 0.75, bg = adjustcolor("white", 0.85))
}

# 5. Fungsi pembuat GIF
buat_gif <- function(h, nama_file, max_iter = 150) {
  ob <- siapkan_animasi(h, max_iter)
  save_gif({
    for (f in ob$frames) gambar_frame(ob, f)
  }, gif_file = nama_file, width = 648, height = 558,
  delay = 1 / 7, res = 90, progress = FALSE)
  ob
}

# 6. BAGIAN A: Mean Shift untuk h = 1.9 (dijalankan lebih dulu)
H_UTAMA  <- 1.9
MAX_ITER <- 150

path_utama <- file.path(OUT_DIR, paste0("meanshift_h", format(H_UTAMA, nsmall = 1), ".gif"))
ob_utama <- buat_gif(H_UTAMA, path_utama, max_iter = MAX_ITER)
ob_utama$path <- path_utama
hasil_utama <- list()
hasil_utama[[as.character(H_UTAMA)]] <- ob_utama
cat(sprintf("selesai h = %s: %d iterasi, %d cluster\n", H_UTAMA, ob_utama$iterasi, ob_utama$k))
## selesai h = 1.9: 8 iterasi, 3 cluster
# Ringkasan hasil h = 1.9
ringkas_utama <- data.frame(
  h              = H_UTAMA,
  iterasi        = ob_utama$iterasi,
  jumlah_cluster = ob_utama$k,
  konvergen      = if (ob_utama$iterasi < MAX_ITER) "ya" else "belum (batas iterasi)",
  row.names = NULL
)
print(ringkas_utama)
##     h iterasi jumlah_cluster konvergen
## 1 1.9       8              3        ya
cat(sprintf("\nh = %s: %d iterasi, %d cluster\n", ob_utama$h, ob_utama$iterasi, ob_utama$k))
## 
## h = 1.9: 8 iterasi, 3 cluster
if (ob_utama$k <= 6) {
  cat("pusat cluster:\n")
  print(round(ob_utama$pusat, 3))
}
## pusat cluster:
##      Fitur1 Fitur2
## [1,]  4.681  1.979
## [2,] -2.644  8.894
## [3,] -6.799 -6.830
par(mfrow = c(1, 1))
gambar_frame(ob_utama, ob_utama$nf - 1)

# GIF yang bergerak untuk h = 1.9
# Fungsi menampilkan GIF yang BERGERAK
tampilkan_gif <- function(daftar_path) {
  tmp <- file.path(tempdir(), "gif_meanshift")
  dir.create(tmp, showWarnings = FALSE)
  file.copy(daftar_path, tmp, overwrite = TRUE)
  img <- sprintf('<div style="width:49%%;display:inline-block"><img src="%s" style="width:100%%"></div>',
                 basename(daftar_path))
  html <- file.path(tmp, "index.html")
  writeLines(c("<html><body style='margin:4px;font-family:sans-serif'>", img,
               "</body></html>"), html)
  viewer <- getOption("viewer")
  if (!is.null(viewer)) viewer(html) else browseURL(html)
}
tampilkan_gif(path_utama)

# 7. BAGIAN B: Mean Shift untuk h = 0.3, 0.4, 1.9, 6.0 (perbandingan, butuh beberapa menit)
H_LIST <- c(0.3, 0.4, 1.9, 6.0)
hasil  <- list()
for (h in H_LIST) {
  if (h == H_UTAMA) {
    ob <- ob_utama                     # h = 1.9 sudah dihitung di bagian A
  } else {
    path <- file.path(OUT_DIR, paste0("meanshift_h", format(h, nsmall = 1), ".gif"))
    ob <- buat_gif(h, path, max_iter = MAX_ITER)
    ob$path <- path
  }
  hasil[[as.character(h)]] <- ob
  cat(sprintf("selesai h = %s: %d iterasi, %d cluster\n", h, ob$iterasi, ob$k))
}
## selesai h = 0.3: 140 iterasi, 17 cluster
## selesai h = 0.4: 69 iterasi, 5 cluster
## selesai h = 1.9: 8 iterasi, 3 cluster
## selesai h = 6: 50 iterasi, 1 cluster
# Ringkasan hasil semua h
ringkas <- data.frame(
  h              = H_LIST,
  iterasi        = sapply(hasil, function(v) v$iterasi),
  jumlah_cluster = sapply(hasil, function(v) v$k),
  konvergen      = sapply(hasil, function(v)
    if (v$iterasi < MAX_ITER) "ya" else "belum (batas iterasi)"),
  row.names = NULL
)
print(ringkas)
##     h iterasi jumlah_cluster konvergen
## 1 0.3     140             17        ya
## 2 0.4      69              5        ya
## 3 1.9       8              3        ya
## 4 6.0      50              1        ya
# Hasil setiap h
for (v in hasil) {
  cat(sprintf("\nh = %s: %d iterasi, %d cluster\n", v$h, v$iterasi, v$k))
  if (v$k <= 6) {
    cat("pusat cluster:\n")
    print(round(v$pusat, 3))
  }
}
## 
## h = 0.3: 140 iterasi, 17 cluster
## 
## h = 0.4: 69 iterasi, 5 cluster
## pusat cluster:
##      Fitur1 Fitur2
## [1,]  4.531  1.800
## [2,]  4.773  4.948
## [3,] -2.726  8.971
## [4,] -7.754 -3.223
## [5,] -6.891 -6.862
## 
## h = 1.9: 8 iterasi, 3 cluster
## pusat cluster:
##      Fitur1 Fitur2
## [1,]  4.681  1.979
## [2,] -2.644  8.894
## [3,] -6.799 -6.830
## 
## h = 6: 50 iterasi, 1 cluster
## pusat cluster:
##      Fitur1 Fitur2
## [1,]  0.823  4.598
par(mfrow = c(2, 2))
for (v in hasil) gambar_frame(v, v$nf - 1)

par(mfrow = c(1, 1))

# GIF yang bergerak untuk semua h
tampilkan_gif(sapply(hasil, function(v) v$path))