Tugas Regresi Spasial

#Library
library(sp)
library(sf)
## Linking to GEOS 3.14.1, GDAL 3.12.1, PROJ 9.7.1; sf_use_s2() is TRUE
library(terra)
## terra 1.9.34
library(gstat)
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:terra':
## 
##     intersect, union
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(ggplot2)
library(viridis)
## Loading required package: viridisLite
library(knitr)
## 
## Attaching package: 'knitr'
## The following object is masked from 'package:terra':
## 
##     spin

Data

Variabel vble menyimpan nilai yang diinterpolasi, yaitu log(zinc). Transformasi log dipakai karena sebaran zinc menjulur ke kanan.

data(meuse)
data(meuse.grid)

d <- st_as_sf(meuse, coords = c("x", "y"), crs = 28992)
d$vble <- log(d$zinc)

grid_sf <- st_as_sf(meuse.grid, coords = c("x", "y"), crs = 28992)

c(n_pengamatan = nrow(d), n_grid_prediksi = nrow(grid_sf))
##    n_pengamatan n_grid_prediksi 
##             155            3103
summary(d$zinc)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   113.0   198.0   326.0   469.7   674.5  1839.0
par(mfrow = c(1, 2))
hist(d$zinc, breaks = 20, col = "grey80", border = "white",
     main = "zinc", xlab = "zinc (ppm)")
hist(d$vble, breaks = 20, col = "steelblue", border = "white",
     main = "log(zinc)", xlab = "log(zinc)")

par(mfrow = c(1, 1))
ggplot() +
  geom_sf(data = grid_sf, color = "grey90", size = 0.3) +
  geom_sf(data = d, aes(color = vble), size = 2) +
  scale_color_viridis(name = "log(zinc)") +
  labs(title = "Sebaran titik pengamatan log(zinc)") +
  theme_bw()

Batas wilayah dan lokasi prediksi

Diagram Voronoi memerlukan batas wilayah. Di sini dipakai convex hull dari seluruh titik pengamatan dan titik grid. Lokasi prediksi adalah titik-titik meuse.grid.

batas <- st_sf(geometry = st_convex_hull(
  st_union(c(st_geometry(d), st_geometry(grid_sf)))
))

ggplot() +
  geom_sf(data = batas, fill = NA, color = "red") +
  geom_sf(data = grid_sf, color = "grey70", size = 0.3) +
  geom_sf(data = d, size = 0.8) +
  labs(title = "Batas wilayah, grid prediksi, dan titik pengamatan") +
  theme_bw()

Fungsi Interpolasi

Setiap metode dibungkus menjadi fungsi dengan argumen train (data yang diketahui) dan newdata (lokasi yang diprediksi). Fungsi yang sama dipakai untuk membuat peta prediksi pada grid dan untuk cross-validation, sehingga hasilnya konsisten.

RMSE

# Root Mean Square Error
RMSE <- function(observed, predicted) sqrt(mean((observed - predicted)^2))

Closest Observation

# 1. Closest observation (poligon Voronoi) ---------------------------------
pred_closest <- function(train, newdata) {
  v <- st_as_sf(voronoi(x = vect(train), bnd = vect(batas)))
  v <- st_sf(pred = v$vble, geometry = st_geometry(v))

  lokasi <- st_sf(.id = seq_len(nrow(newdata)), geometry = st_geometry(newdata))
  j <- st_join(lokasi, v, join = st_intersects)
  j <- j[!duplicated(j$.id), ]          # titik tepat di sisi poligon dihitung sekali
  j <- j[order(j$.id), ]
  p <- j$pred

  # cadangan: bila ada titik di luar semua poligon, pakai titik terdekat
  if (anyNA(p)) {
    na <- which(is.na(p))
    p[na] <- train$vble[st_nearest_feature(lokasi[na, ], train)]
  }
  p
}

K-Nearest Neighbors

# 2. Nearest neighbors: rata-rata k tetangga terdekat (idp = 0 -> bobot sama)
pred_nn <- function(train, newdata, k = 5) {
  g <- gstat(formula = vble ~ 1, locations = train, nmax = k, set = list(idp = 0))
  predict(g, newdata, debug.level = 0)$var1.pred
}

Inverse Distance Weighting

# 3. IDW: seluruh titik dipakai, bobot 1 / jarak^idp -------------------------
pred_idw <- function(train, newdata, idp = 1) {
  g <- gstat(formula = vble ~ 1, locations = train,
             nmax = nrow(train), set = list(idp = idp))
  predict(g, newdata, debug.level = 0)$var1.pred
}

Ordinary Kriging

# 5. Ordinary kriging: semivariogram di-fit ulang pada data `train` ---------
fit_variogram <- function(train) {
  v <- variogram(vble ~ 1, data = train)
  fit.variogram(v, model = vgm(psill = 0.5, model = "Sph", range = 900, nugget = 0.1))
}

pred_ok <- function(train, newdata) {
  g <- gstat(formula = vble ~ 1, data = train, model = fit_variogram(train))
  predict(g, newdata, debug.level = 0)
}

Metode Deterministik

Closest Observation

Ruang dibagi menjadi poligon Voronoi. Setiap titik di dalam suatu poligon lebih dekat ke titik pengamatan pemilik poligon itu daripada ke titik lain, sehingga prediksinya sama dengan nilai titik tersebut.

vor <- st_as_sf(voronoi(x = vect(d), bnd = vect(batas)))

ggplot() +
  geom_sf(data = vor, aes(fill = vble), color = "white", linewidth = 0.1, alpha = 0.8) +
  geom_sf(data = d, size = 0.6) +
  scale_fill_viridis(name = "log(zinc)") +
  labs(title = "Diagram Voronoi (Closest Observation)") +
  theme_bw()

p_closest <- pred_closest(d, grid_sf)

Nearest Neighbors

Prediksi adalah rata-rata nilai \(k = 5\) titik terdekat, masing-masing dengan bobot sama.

p_nn <- pred_nn(d, grid_sf, k = 5)

Inverse Distance Weighting (IDW)

Seluruh titik pengamatan dipakai dengan bobot yang menurun seiring jarak, \(w_i \propto 1/d_i^{p}\). Seperti pada benchmark, dipakai \(p = 1\).

p_idw <- pred_idw(d, grid_sf, idp = 1)

Pendekatan Ensembel

Ensembel menggabungkan prediksi tiga metode deterministik di atas (Closest Observation, Nearest Neighbors, IDW):

\[\hat{z}_{ens}(s_0) = w_1 \hat{z}_{closest}(s_0) + w_2 \hat{z}_{NN}(s_0) + w_3 \hat{z}_{IDW}(s_0), \qquad \sum_j w_j = 1\]

Dua skema bobot dibandingkan:

  1. Bobot sama: \(w_j = 1/3\).
  2. Bobot invers RMSE: metode dengan RMSE lebih kecil mendapat bobot lebih besar, \(w_j \propto 1/RMSE_j\).
# bobot invers RMSE dari inner k-fold CV pada data `train`
hitung_bobot <- function(train, k = 5) {
  kf_in <- dismo::kfold(nrow(train), k = k)
  P <- matrix(NA_real_, nrow(train), 3,
              dimnames = list(NULL, c("Closest", "NN", "IDW")))
  for (i in 1:k) {
    tr <- train[kf_in != i, ]
    te <- train[kf_in == i, ]
    P[kf_in == i, ] <- cbind(pred_closest(tr, te), pred_nn(tr, te), pred_idw(tr, te))
  }
  w <- 1 / apply(P, 2, function(p) RMSE(train$vble, p))
  w / sum(w)
}

set.seed(123)
w_full <- hitung_bobot(d)
round(w_full, 4)
## Closest      NN     IDW 
##  0.3326  0.3640  0.3034
p_ens_eq <- (p_closest + p_nn + p_idw) / 3
p_ens_w  <- w_full["Closest"] * p_closest + w_full["NN"] * p_nn + w_full["IDW"] * p_idw

Metode Stokastik: Ordinary Kriging

Ordinary Kriging memanfaatkan autokorelasi spasial melalui semivariogram, dengan asumsi rata-rata konstan tetapi tidak diketahui.

Semivariogram empiris

vc <- variogram(vble ~ 1, d, cloud = TRUE)
plot(vc)

v_emp <- variogram(vble ~ 1, data = d)
plot(v_emp)

Fitting model semivariogram

Model awal spherical dengan psill = 0.5, range = 900, dan nugget = 0.1 (sama seperti benchmark), kemudian dioptimasi dengan fit.variogram().

v_init <- vgm(psill = 0.5, model = "Sph", range = 900, nugget = 0.1)
fv <- fit.variogram(v_emp, model = v_init)
fv
##   model      psill    range
## 1   Nug 0.05066017   0.0000
## 2   Sph 0.59060556 897.0044
plot(v_emp, fv, cex = 1.5)

Parameter hasil fitting: nugget = 0.0507, partial sill = 0.5906 (sill total = 0.6413), dan range = 897 meter. Artinya, titik yang berjarak lebih dari sekitar 897 m dianggap tidak lagi berkorelasi secara spasial.

Prediksi Kriging

g_ok <- gstat(formula = vble ~ 1, data = d, model = fv)
kpred <- predict(g_ok, grid_sf, debug.level = 0)
p_ok <- kpred$var1.pred

Peta Prediksi Seluruh Metode

xy <- st_coordinates(grid_sf)

peta <- bind_rows(
  data.frame(metode = "Closest Observation",     pred = p_closest),
  data.frame(metode = "Nearest Neighbors",        pred = p_nn),
  data.frame(metode = "IDW",                      pred = p_idw),
  data.frame(metode = "Ensembel (bobot invers RMSE)", pred = as.numeric(p_ens_w)),
  data.frame(metode = "Ordinary Kriging",         pred = p_ok)
) %>%
  mutate(x = rep(xy[, 1], 5), y = rep(xy[, 2], 5),
         metode = factor(metode, levels = unique(metode)))

ggplot(peta, aes(x, y, fill = pred)) +
  geom_raster() +
  coord_equal() +
  facet_wrap(~ metode, ncol = 2, labeller = label_wrap_gen(28)) +
  scale_fill_viridis(name = "log(zinc)") +
  labs(title = "Prediksi log(zinc) pada grid meuse", x = NULL, y = NULL) +
  theme_bw() +
  theme(axis.text = element_blank(), axis.ticks = element_blank())

Varians prediksi Kriging (hanya Kriging yang menghasilkan ukuran ketidakpastian prediksi):

ggplot() +
  geom_sf(data = kpred, aes(color = var1.var), size = 1) +
  geom_sf(data = d, size = 0.6) +
  scale_color_viridis(name = "varians") +
  labs(title = "Varians prediksi Ordinary Kriging") +
  theme_bw()

Varians kecil di sekitar titik pengamatan dan membesar di area yang jauh dari titik pengamatan.

Evaluasi Model: Cross-Validation

Data dibagi menjadi \(K = 5\) fold dengan dismo::kfold(). Pada setiap iterasi, satu fold menjadi data uji dan empat fold lainnya menjadi data latih. Pada setiap iterasi, seluruh langkah dihitung ulang hanya dari data latih: prediksi tiap metode, bobot ensembel (lewat inner CV), dan semivariogram Kriging.

set.seed(123)
K  <- 5
kf <- dismo::kfold(nrow(d), k = K)

metode <- c(closest     = "Closest Observation",
            nn          = "Nearest Neighbors (k = 5)",
            idw         = "IDW (p = 1)",
            ensemble_w  = "Ensembel (bobot invers RMSE)",
            ensemble_eq = "Ensembel (bobot sama)",
            kriging     = "Ordinary Kriging")

oof <- data.frame(fold = kf, obs = d$vble,
                  matrix(NA_real_, nrow(d), length(metode),
                         dimnames = list(NULL, names(metode))))
bobot_fold <- matrix(NA_real_, K, 3,
                     dimnames = list(paste("Fold", 1:K), c("Closest", "NN", "IDW")))

for (k in 1:K) {
  test  <- d[kf == k, ]
  train <- d[kf != k, ]
  i <- which(kf == k)

  p1 <- pred_closest(train, test)
  p2 <- pred_nn(train, test)
  p3 <- pred_idw(train, test)
  w  <- hitung_bobot(train)            # bobot hanya dari data latih
  bobot_fold[k, ] <- w

  oof$closest[i]     <- p1
  oof$nn[i]          <- p2
  oof$idw[i]         <- p3
  oof$ensemble_w[i]  <- w[1] * p1 + w[2] * p2 + w[3] * p3
  oof$ensemble_eq[i] <- (p1 + p2 + p3) / 3
  oof$kriging[i]     <- pred_ok(train, test)$var1.pred
}

Bobot ensembel (invers RMSE) yang dipakai pada tiap fold:

kable(bobot_fold, digits = 4)
Closest NN IDW
Fold 1 0.3367 0.3687 0.2947
Fold 2 0.3122 0.3883 0.2994
Fold 3 0.3436 0.3661 0.2904
Fold 4 0.3182 0.3827 0.2990
Fold 5 0.3437 0.3704 0.2859

RMSE pada setiap fold

rmse_fold <- sapply(names(metode), function(m)
  tapply(seq_len(nrow(oof)), oof$fold, function(i) RMSE(oof$obs[i], oof[[m]][i])))
colnames(rmse_fold) <- metode
rownames(rmse_fold) <- paste("Fold", 1:K)

kable(rmse_fold, digits = 4, caption = "RMSE tiap fold (skala log(zinc))")
RMSE tiap fold (skala log(zinc))
Closest Observation Nearest Neighbors (k = 5) IDW (p = 1) Ensembel (bobot invers RMSE) Ensembel (bobot sama) Ordinary Kriging
Fold 1 0.6327 0.4958 0.5496 0.4702 0.4707 0.4281
Fold 2 0.5987 0.4943 0.5828 0.4718 0.4733 0.4183
Fold 3 0.6328 0.6516 0.7843 0.6330 0.6379 0.5055
Fold 4 0.4874 0.5316 0.6333 0.4907 0.4915 0.4198
Fold 5 0.5593 0.4791 0.6293 0.4768 0.4820 0.3840

Rata-rata RMSE

hasil <- data.frame(Metode = metode,
                    RMSE_rata2 = colMeans(rmse_fold),
                    SD_antar_fold = apply(rmse_fold, 2, sd),
                    row.names = NULL) %>%
  arrange(RMSE_rata2) %>%
  mutate(Peringkat = row_number(),
         Selisih_vs_terbaik = RMSE_rata2 - min(RMSE_rata2)) %>%
  select(Peringkat, Metode, RMSE_rata2, SD_antar_fold, Selisih_vs_terbaik)

kable(hasil, digits = 4,
      caption = "Rata-rata RMSE 5-fold CV (skala log(zinc)); makin kecil makin baik")
Rata-rata RMSE 5-fold CV (skala log(zinc)); makin kecil makin baik
Peringkat Metode RMSE_rata2 SD_antar_fold Selisih_vs_terbaik
1 Ordinary Kriging 0.4312 0.0449 0.0000
2 Ensembel (bobot invers RMSE) 0.5085 0.0701 0.0773
3 Ensembel (bobot sama) 0.5111 0.0714 0.0799
4 Nearest Neighbors (k = 5) 0.5305 0.0704 0.0993
5 Closest Observation 0.5822 0.0610 0.1510
6 IDW (p = 1) 0.6358 0.0899 0.2047
urutan <- rev(hasil$Metode)   # terbaik di atas setelah coord_flip
rmse_long <- data.frame(Metode = factor(rep(metode, each = K), levels = urutan),
                        Fold = rep(1:K, times = length(metode)),
                        RMSE = as.vector(rmse_fold))
hasil_plot <- transform(hasil, Metode = factor(Metode, levels = urutan))

ggplot(hasil_plot, aes(x = Metode, y = RMSE_rata2)) +
  geom_col(fill = "steelblue", alpha = 0.8) +
  geom_point(data = rmse_long, aes(y = RMSE), shape = 21, fill = "white", size = 2) +
  geom_text(aes(y = 0, label = sprintf("%.3f", RMSE_rata2)), hjust = -0.2, color = "white", fontface = "bold", size = 3.8) +
  coord_flip() +
  labs(title = "Perbandingan RMSE (5-fold CV)",
       subtitle = "Batang = rata-rata 5 fold; titik = RMSE tiap fold",
       x = NULL, y = "RMSE log(zinc)") +
  theme_bw()

RMSE pada skala asli (zinc, ppm)

RMSE di atas berada pada skala log. Untuk interpretasi pada satuan asli, prediksi dikembalikan dengan exp() (back-transform sederhana, tanpa koreksi bias) lalu dibandingkan dengan nilai zinc sebenarnya.

rmse_asli <- sapply(names(metode), function(m)
  tapply(seq_len(nrow(oof)), oof$fold,
         function(i) RMSE(exp(oof$obs[i]), exp(oof[[m]][i]))))
colnames(rmse_asli) <- metode

kable(data.frame(Metode = metode, RMSE_zinc_ppm = colMeans(rmse_asli), row.names = NULL) %>%
        arrange(RMSE_zinc_ppm),
      digits = 2, caption = "Rata-rata RMSE 5-fold CV pada skala zinc (ppm)")
Rata-rata RMSE 5-fold CV pada skala zinc (ppm)
Metode RMSE_zinc_ppm
Ordinary Kriging 238.30
Ensembel (bobot invers RMSE) 292.23
Ensembel (bobot sama) 293.97
Nearest Neighbors (k = 5) 302.36
Closest Observation 311.25
IDW (p = 1) 350.54

Observasi vs prediksi

oof_long <- do.call(rbind, lapply(names(metode), function(m)
  data.frame(Metode = metode[[m]], obs = oof$obs, pred = oof[[m]])))
oof_long$Metode <- factor(oof_long$Metode, levels = metode)

ggplot(oof_long, aes(obs, pred)) +
  geom_abline(slope = 1, intercept = 0, color = "red") +
  geom_point(alpha = 0.5, size = 1) +
  facet_wrap(~ Metode, ncol = 3) +
  labs(title = "Nilai observasi vs prediksi CV", x = "Observasi log(zinc)", y = "Prediksi log(zinc)") +
  theme_bw()

Kesimpulan

  • Berdasarkan rata-rata RMSE 5-fold CV, metode terbaik adalah Ordinary Kriging (RMSE = 0.4312), sedangkan yang terburuk adalah IDW (p = 1) (RMSE = 0.6358).
  • Ordinary Kriging memiliki RMSE 0.4312, dibandingkan 0.5305 untuk metode deterministik tunggal terbaik.
  • Kriging juga menghasilkan varians prediksi sebagai ukuran ketidakpastian, yang tidak tersedia pada metode deterministik maupun ensembel.
  • Ensembel (bobot invers RMSE) memiliki RMSE 0.5085, yaitu lebih kecil daripada anggota terbaiknya (0.5305). Ensembel tidak otomatis mengalahkan anggota terbaiknya: ia hanya meredam kesalahan anggota yang lemah (Closest Observation dan IDW) dan terbukti lebih baik daripada rata-rata ketiga anggotanya (0.5828). Bobot ensembel dihitung hanya dari data latih (tanpa kebocoran informasi dari data uji), sehingga RMSE-nya dapat dibandingkan secara jujur dengan metode lain.