Tugas Interpolasi Spasial

Persiapan paket dan data

paket <- c("sp", "sf", "gstat", "terra", "ggplot2", "viridis", "patchwork", "knitr")
baru  <- paket[!paket %in% rownames(installed.packages())]
if (length(baru) > 0) install.packages(baru)

library(sp)
library(sf)
library(gstat)
library(terra)
library(ggplot2)
library(viridis)
library(patchwork)
library(knitr)

Data curah hujan Bengawan Solo

Data berasal dari dataset Daily Rainfall Dataset for 68 Rainfall Stations in the Bengawan Solo Water Resources Region (Mendeley Data), terdiri dari dua berkas:

  • pos_hujan.csv: metadata pos hujan (nama dan koordinat).
  • data_hujan.csv: curah hujan harian 1980-2025, dihubungkan lewat pos_id.

Penyesuaian data untuk interpolasi:

  • Kolom rain (pengamatan manual) yang dipakai. Kolom total (telemetri) tidak dipakai karena hampir seluruhnya bernilai 0 sebelum tahun 2017.
  • Periode dibatasi 2020-2025, supaya seluruh pos dibandingkan pada rentang waktu yang sama.
  • Pos dengan kurang dari 730 hari pengamatan (sekitar 2 tahun) dibuang, karena rata-ratanya bisa bias musiman.
  • Peubah yang diinterpolasi: log(rata-rata curah hujan harian, mm/hari) per pos. Transformasi log dipakai karena sebaran rata-ratanya miring ke kanan.
  • Koordinat diubah dari lon/lat ke UTM zona 49S (EPSG:32749) sehingga jarak dalam meter.
  • set.seed(147) dipakai agar pembagian lipatan pada K-fold Cross-Validation (sample()) reproducible. Angka seed bebas; yang penting tetap sama setiap kali knit. Tanpa seed ini, nilai RMSE per lipatan bisa berubah tiap run sehingga kesimpulan tidak stabil.
set.seed(147)

# Lokasi berkas: dicari di folder D:/SEM 5/MPDW, lalu di folder kerja Rmd ini
kandidat <- c("D:/SEM 5/MPDW", ".")
dir_data <- kandidat[file.exists(file.path(kandidat, "pos_hujan.csv")) &
                     file.exists(file.path(kandidat, "data_hujan.csv"))][1]
if (is.na(dir_data)) {
  stop("pos_hujan.csv dan data_hujan.csv tidak ditemukan. ",
       "Letakkan keduanya di D:/SEM 5/MPDW atau di folder yang sama dengan berkas Rmd ini.")
}

pos   <- read.csv(file.path(dir_data, "pos_hujan.csv"),  stringsAsFactors = FALSE)
hujan <- read.csv(file.path(dir_data, "data_hujan.csv"), stringsAsFactors = FALSE)

# Koordinat tertulis "lat, lon" dalam satu kolom teks
ll <- do.call(rbind, strsplit(pos$koordinat, ","))
pos$lat <- as.numeric(trimws(ll[, 1]))
pos$lon <- as.numeric(trimws(ll[, 2]))

# Periode 2020-2025, hanya hari yang ada pengamatan manualnya
hujan <- hujan[!is.na(hujan$rain) & hujan$tanggal >= "2020-01-01", ]

rata   <- tapply(hujan$rain, hujan$pos_id, mean)
n_hari <- tapply(hujan$rain, hujan$pos_id, length)
agg <- data.frame(pos_id = as.integer(names(rata)),
                  n_hari = as.integer(n_hari),
                  rata   = as.numeric(rata))

cat("Pos yang punya data rain 2020-2025 :", nrow(agg), "\n")
## Pos yang punya data rain 2020-2025 : 64
agg <- agg[agg$n_hari >= 730, ]
cat("Pos dengan >= 730 hari pengamatan  :", nrow(agg), "\n")
## Pos dengan >= 730 hari pengamatan  : 58
dat <- merge(agg, pos[, c("pos_id", "nama_pos", "lat", "lon")], by = "pos_id")
dat <- dat[!is.na(dat$lat) & !is.na(dat$lon), ]

d <- st_as_sf(dat, coords = c("lon", "lat"), crs = 4326)
d <- st_transform(d, 32749)

# Peubah yang diinterpolasi
d$vble <- log(d$rata)

# Grid prediksi 3 km x 3 km di dalam convex hull pos hujan (+ penyangga 5 km)
hull <- st_buffer(st_convex_hull(st_union(d)), 5000)
gp   <- st_make_grid(hull, cellsize = 3000, what = "centers")
coop <- st_sf(geometry = gp[st_intersects(gp, hull, sparse = FALSE)[, 1]])

cat("Jumlah titik observasi :", nrow(d), "\n")
## Jumlah titik observasi : 58
cat("Jumlah titik prediksi  :", nrow(coop), "\n")
## Jumlah titik prediksi  : 2193
summary(d$vble)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   1.419   1.695   1.796   1.820   1.930   2.330
ggplot() +
  geom_sf(data = coop, color = "grey85", size = 0.3) +
  geom_sf(data = d, aes(color = vble), size = 2.5) +
  scale_color_viridis_c(name = "log(CH harian)") +
  labs(title = "Titik observasi log(rata-rata curah hujan harian), 2020-2025") +
  theme_bw()

Fungsi prediksi untuk setiap metode

# 1. Closest observation: nilai titik observasi terdekat.
pred_closest <- function(train, newdata) {
  idx <- st_nearest_feature(newdata, train)
  train$vble[idx]
}

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

# 3. 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
}

# 4. Ordinary kriging: variogram dihitung dan di-fit ulang dari data train.
fit_vgm <- function(train) {
  bb    <- st_bbox(train)
  diagn <- sqrt((bb[3] - bb[1])^2 + (bb[4] - bb[2])^2)   # diagonal wilayah (m)
  vv    <- var(train$vble)
  vinit <- vgm(psill = 0.7 * vv, model = "Sph",
               range = as.numeric(diagn) / 4, nugget = 0.3 * vv)
  v0 <- variogram(vble ~ 1, train)
  fv <- tryCatch(suppressWarnings(fit.variogram(v0, model = vinit)),
                 error = function(e) vinit)
  # cadangan jika fitting gagal atau menghasilkan parameter tidak valid
  if (isTRUE(attr(fv, "singular")) || any(fv$psill < 0) || fv$range[2] <= 0) fv <- vinit
  fv
}

pred_ok <- function(train, newdata) {
  fv <- fit_vgm(train)
  krige(vble ~ 1, locations = train, newdata = newdata,
        model = fv, debug.level = 0)$var1.pred
}

RMSE <- function(obs, pred) sqrt(mean((obs - pred)^2))

Peta hasil interpolasi (seluruh data)

Poligon Voronoi (ilustrasi Closest Observation)

batas <- st_sf(geometry = st_buffer(hull, 20000))

tryCatch({
  v <- terra::voronoi(terra::vect(d), bnd = terra::vect(batas))
  v <- st_as_sf(v)
  print(
    ggplot() +
      geom_sf(data = v, aes(fill = vble), alpha = 0.7, color = "white", linewidth = 0.1) +
      geom_sf(data = d, size = 0.8) +
      scale_fill_viridis_c(name = "log(CH harian)") +
      coord_sf(xlim = st_bbox(coop)[c(1, 3)], ylim = st_bbox(coop)[c(2, 4)]) +
      labs(title = "Poligon Voronoi (Closest Observation)") +
      theme_bw()
  )
}, error = function(e) message("Plot Voronoi dilewati: ", conditionMessage(e)))

Variogram dan model Kriging

v_emp <- variogram(vble ~ 1, d)
fv    <- fit_vgm(d)
fv
##   model       psill    range
## 1   Nug 0.005473891     0.00
## 2   Sph 0.028961008 43792.57
plot(v_emp, fv, cex = 1.3, main = "Variogram empiris dan model terfit (seluruh data)")

Prediksi pada seluruh grid

p_closest <- pred_closest(d, coop)
p_idw     <- pred_idw(d, coop)
p_nn      <- pred_nn(d, coop)
kr        <- krige(vble ~ 1, d, coop, model = fv, debug.level = 0)
p_ok      <- kr$var1.pred

Evaluasi dengan K-fold Cross-Validation

Data dibagi menjadi K = 5 lipatan. Untuk tiap lipatan, 4 bagian menjadi data latih dan 1 bagian menjadi data uji. RMSE dihitung pada data uji, lalu dirata-ratakan.

Dua versi ensemble dibandingkan (komponennya: Closest, NN, IDW, dan Kriging):

  • Ensemble (bobot sama): bobot 1/4 untuk tiap metode.
  • Ensemble (bobot 1/RMSE): bobot berbanding terbalik dengan RMSE, seperti pada materi. RMSE yang dipakai untuk bobot dihitung pada lipatan uji yang sama, jadi hasilnya cenderung optimistis (ada “mengintip” data uji).
K  <- 5
n  <- nrow(d)
kf <- sample(rep(1:K, length.out = n))   # penetapan lipatan acak

hasil <- data.frame(fold = 1:K, Closest = NA, NN = NA, IDW = NA,
                    Kriging = NA, Ens_Sama = NA, Ens_InvRMSE = NA)

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

  p1 <- pred_closest(train, test)
  p2 <- pred_nn(train, test)
  p3 <- pred_idw(train, test)
  p4 <- pred_ok(train, test)

  r <- c(RMSE(test$vble, p1), RMSE(test$vble, p2),
         RMSE(test$vble, p3), RMSE(test$vble, p4))

  # Ensemble bobot sama
  p5 <- (p1 + p2 + p3 + p4) / 4
  # Ensemble bobot 1/RMSE
  w  <- (1 / r) / sum(1 / r)
  p6 <- w[1] * p1 + w[2] * p2 + w[3] * p3 + w[4] * p4

  hasil[k, -1] <- c(r, RMSE(test$vble, p5), RMSE(test$vble, p6))
}

kable(hasil, digits = 4, caption = "RMSE per lipatan (skala log curah hujan harian)")
RMSE per lipatan (skala log curah hujan harian)
fold Closest NN IDW Kriging Ens_Sama Ens_InvRMSE
1 0.1517 0.2030 0.1764 0.1920 0.1776 0.1752
2 0.1524 0.1716 0.1509 0.1461 0.1296 0.1293
3 0.2840 0.1593 0.1515 0.1921 0.1847 0.1738
4 0.2223 0.1652 0.1465 0.1560 0.1617 0.1584
5 0.1627 0.1659 0.1542 0.1604 0.1556 0.1555
ringkas <- data.frame(
  Kode   = c("Closest", "NN", "IDW", "Kriging", "Ens_Sama", "Ens_InvRMSE"),
  Metode = c("Closest Observation", "Nearest Neighbors (k=5)", "IDW (p=2)",
             "Ordinary Kriging", "Ensemble (bobot sama)", "Ensemble (bobot 1/RMSE)"),
  RMSE_rata2 = colMeans(hasil[, -1]),
  stringsAsFactors = FALSE
)
ringkas <- ringkas[order(ringkas$RMSE_rata2), ]
rownames(ringkas) <- NULL
kable(ringkas[, c("Metode", "RMSE_rata2")], digits = 4,
      caption = "Rata-rata RMSE 5-fold CV (diurutkan, terkecil = terbaik)")
Rata-rata RMSE 5-fold CV (diurutkan, terkecil = terbaik)
Metode RMSE_rata2
IDW (p=2) 0.1559
Ensemble (bobot 1/RMSE) 0.1584
Ensemble (bobot sama) 0.1618
Ordinary Kriging 0.1693
Nearest Neighbors (k=5) 0.1730
Closest Observation 0.1946
rg <- ringkas
rg$Metode <- factor(rg$Metode, levels = rev(rg$Metode))
ggplot(rg, aes(x = Metode, y = RMSE_rata2, fill = RMSE_rata2)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = round(RMSE_rata2, 3)), hjust = -0.1) +
  coord_flip(ylim = c(0, max(rg$RMSE_rata2) * 1.15)) +
  scale_fill_viridis_c(direction = -1, guide = "none") +
  labs(title = "Perbandingan RMSE (5-fold CV)", x = NULL,
       y = "RMSE (log curah hujan harian)") +
  theme_bw()

Peta perbandingan semua metode

# Bobot ensemble untuk peta akhir: 1/RMSE rata-rata hasil CV (tanpa mengintip data uji)
rm_cv <- colMeans(hasil[, c("Closest", "NN", "IDW", "Kriging")])
w_cv  <- (1 / rm_cv) / sum(1 / rm_cv)
round(w_cv, 3)
## Closest      NN     IDW Kriging 
##   0.221   0.249   0.276   0.254
p_ens <- w_cv[["Closest"]] * p_closest + w_cv[["NN"]] * p_nn +
         w_cv[["IDW"]] * p_idw + w_cv[["Kriging"]] * p_ok
lim <- range(c(p_closest, p_nn, p_idw, p_ok, p_ens))
xy  <- st_coordinates(coop)

peta <- function(pred, judul) {
  df <- data.frame(X = xy[, 1], Y = xy[, 2], pred = as.numeric(pred))
  ggplot(df, aes(X, Y, fill = pred)) +
    geom_raster() +
    scale_fill_viridis_c(name = "log(CH harian)", limits = lim) +
    coord_equal() +
    labs(title = judul, x = NULL, y = NULL) +
    theme_bw(base_size = 9)
}

(peta(p_closest, "Closest Observation") | peta(p_nn, "Nearest Neighbors (k=5)")) /
(peta(p_idw, "IDW (p = 2)")             | peta(p_ok, "Ordinary Kriging")) /
(peta(p_ens, "Ensemble (bobot 1/RMSE CV)") | plot_spacer()) +
  plot_layout(guides = "collect")

Peta varians Kriging

df_v <- data.frame(X = xy[, 1], Y = xy[, 2], v = kr$var1.var)
ggplot(df_v, aes(X, Y, fill = v)) +
  geom_raster() + coord_equal() +
  scale_fill_viridis_c(name = "varians") +
  geom_sf(data = d, inherit.aes = FALSE, size = 0.8, color = "white") +
  labs(title = "Varians prediksi Ordinary Kriging", x = NULL, y = NULL) +
  theme_bw()

Kesimpulan

Dari 5-fold cross-validation pada rata-rata curah hujan harian 2020–2025 di 58 pos hujan Bengawan Solo (pakai log curah hujan harian), IDW dengan p=2 dapat RMSE rata-rata paling kecil, 0,1559, dan menang di 3 dari 5 lipatan. Selisihnya dengan Closest Observation yang paling buruk (0,1946) sekitar 20%. Urutan lengkap: IDW (0,1559), Ensemble 1/RMSE (0,1584), Ensemble bobot sama (0,1618), Ordinary Kriging (0,1693), NN k=5 (0,1730), Closest Observation (0,1946).

Ordinary Kriging justru bukan yang terbaik. Kemungkinan karena pos hujannya cuma 58, jadi tiap lipatan hanya punya sekitar 46 titik latih, dan variogram susah diestimasi stabil (nugget sekitar 16% sill, range sekitar 44 km). Curah hujan yang dipengaruhi topografi juga melemahkan struktur spasialnya. Closest Observation wajar paling buruk karena hanya pakai satu titik terdekat, dan petanya memang kasar dan berpetak-petak.

Kedua ensemble tidak mengalahkan metode tunggal terbaik. Ensemble 1/RMSE (0,1584) dan bobot sama (0,1618) masih di atas IDW (0,1559), meski lebih baik dari 3 dari 4 metode penyusunnya, karena Closest dan NN ikut menarik hasil. Bobot peta akhir: IDW 0,276, Kriging 0,254, NN 0,249, Closest 0,221. Bobot terbesar memang ke IDW, tapi selisihnya tidak besar, jadi hasil ensemble hampir mirip rata-rata biasa.

Satu catatan: K-fold acak pada data spasial cenderung optimistis karena titik uji biasanya dekat dengan titik latih, dan bobot 1/RMSE per lipatan memakai info data uji. Dengan 58 pos, tiap lipatan uji isinya sekitar 11 titik, jadi RMSE per lipatan naik-turun dan peringkat metode yang selisihnya tipis bisa berubah kalau set.seed() diganti. Jadi IDW, Ensemble, dan Kriging sebaiknya dibaca sebagai kelompok yang mirip performanya, bukan perbedaan yang tegas.