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 lewatpos_id.
Penyesuaian data untuk interpolasi:
- Kolom
rain(pengamatan manual) yang dipakai. Kolomtotal(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
## 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
## Jumlah titik prediksi : 2193
## 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
## model psill range
## 1 Nug 0.005473891 0.00
## 2 Sph 0.028961008 43792.57
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)")| 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)")| 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_oklim <- 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.