Statistika lingkungan berperan penting dalam membantu memahami kondisi ekosistem melalui analisis data yang diperoleh dari pengukuran lapangan. Pada konteks ini, mahasiswa diharapkan mampu menerapkan teknik pembersihan data (data cleaning), klasifikasi, dan prediksi terhadap dataset lingkungan yang memiliki permasalahan khas dunia nyata, seperti missing value, outlier, dan inkonsistensi data kategorik. Ujian ini menggunakan dataset simulasi hasil pengukuran kualitas air sungai di Kabupaten X. Dataset tersebut mencakup parameter fisik dan kimia air seperti pH, DO, BOD, TSS, dan Suhu, serta kategori Status Kualitas Air. Mahasiswa diharapkan mampu melakukan eksplorasi dan pembersihan data, menerapkan model klasifikasi untuk menentukan status kualitas air, dan membangun model prediksi.
Data telah dibagi menjadi dua bagian:
1. Data Training sebanyak 300 baris 2. Data Testing sebanyak 75 baris
(kolom status telah dihapus)
3. Dataset dapat diakses di sini: https://docs.google.com/spreadsheets/d/1p7jtVXr9_FDHBALoLkddTzYgtWV46BkN/edit?usp=sharing&rtpof=true&sd=true
# load library
library(readxl)
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(stringr)
# load dataset
file_path <- "D:/[UNNES]/SEMESTER 5 [22 SKS]/Statistika Lingkungan [2 SKS]/P8/kualitasair.xlsx"
# baca sheet training & testing
train <- read_excel(file_path, sheet = 1)
test <- read_excel(file_path, sheet = 2)
# cek struktur awal
glimpse(train)
## Rows: 300
## Columns: 7
## $ Lokasi <chr> "S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "S10", "S…
## $ pH <dbl> 7.6855, 6.7177, 7.1816, 7.3164, 7.2021, 6.9469, 7.7558, 6.9527,…
## $ DO <dbl> NA, 5.7236, 4.8906, 6.1339, 7.7853, 8.4222, 4.9232, 6.4859, 7.3…
## $ BOD <dbl> 1.7136, 1.4402, 2.7274, 3.1398, 1.1778, 3.2324, NA, 4.0358, 3.1…
## $ TSS <dbl> 43.1415, 44.2963, NA, 41.0104, 48.0967, 48.5610, 49.0343, 51.81…
## $ Suhu <dbl> 26.7972, 27.7284, 26.0255, 29.6639, 26.4099, 28.6809, 29.7409, …
## $ Status <chr> "Tercemar ringan", "Tercemar ringan", "Tercemar ringan", "Terce…
glimpse(test)
## Rows: 75
## Columns: 6
## $ Lokasi <chr> "S301", "S302", "S303", "S304", "S305", "S306", "S307", "S308",…
## $ pH <dbl> 6.9977, 7.3801, 7.0195, 7.3675, 6.9268, 6.9711, 7.2412, 7.4965,…
## $ DO <dbl> 5.0835, 4.7482, 6.5949, NA, 6.2444, 6.0028, 4.6718, 7.1797, 5.4…
## $ BOD <dbl> 3.1813, 3.3373, 3.0039, 3.4952, 3.3449, 3.4466, 3.3968, 4.3295,…
## $ TSS <dbl> 50.9339, 46.5210, NA, 39.0091, 47.1692, 39.1027, 45.9433, 55.26…
## $ Suhu <dbl> 25.5573, 27.0940, 26.5954, 26.6512, 23.3940, 27.7013, 29.8974, …
colSums(is.na(train))
## Lokasi pH DO BOD TSS Suhu Status
## 0 0 23 22 24 0 0
colSums(is.na(test))
## Lokasi pH DO BOD TSS Suhu
## 0 0 8 9 7 0
# mengatasi missing value
num_cols <- c("pH", "DO", "BOD", "TSS", "Suhu")
# imputasi median di data training
for (col in num_cols) {
med <- median(train[[col]], na.rm = TRUE)
train[[col]][is.na(train[[col]])] <- med
}
# imputasi median di data testing
for (col in num_cols) {
med <- median(test[[col]], na.rm = TRUE)
test[[col]][is.na(test[[col]])] <- med
}
# cek ulang, harusnya semua udah 0
colSums(is.na(train))
## Lokasi pH DO BOD TSS Suhu Status
## 0 0 0 0 0 0 0
colSums(is.na(test))
## Lokasi pH DO BOD TSS Suhu
## 0 0 0 0 0 0
library(dplyr)
library(ggplot2)
# deteksi outlier dengan boxplot
num_cols <- c("pH", "DO", "BOD", "TSS", "Suhu")
par(mfrow = c(2,3))
for (col in num_cols) {
boxplot(train[[col]], main = paste("Boxplot", col), col = "#99CCFF")
}
# menghapus outlier ekstrem
for (col in num_cols) {
Q1 <- quantile(train[[col]], 0.25, na.rm = TRUE)
Q3 <- quantile(train[[col]], 0.75, na.rm = TRUE)
IQR <- Q3 - Q1
lower <- Q1 - 1.5 * IQR
upper <- Q3 + 1.5 * IQR
train[[col]][train[[col]] < lower] <- lower
train[[col]][train[[col]] > upper] <- upper
}
# inkonsistensi kategori (kolom Status)
unique(train$Status)
## [1] "Tercemar ringan" "baik" "BAIK" "Baik"
## [5] "tercemar ringan" "Tercemar Ringan" "Tercemar berat"
# konsistenkan kategori
train$Status <- tolower(train$Status)
train$Status <- trimws(train$Status)
train$Status <- recode(train$Status,
"baik" = "baik",
"tercemar ringan" = "tercemar ringan",
"tercemar berat" = "tercemar berat")
unique(train$Status)
## [1] "tercemar ringan" "baik" "tercemar berat"
# summary desc stats
summary(train)
## Lokasi pH DO BOD
## Length:300 Min. :5.697 Min. :3.615 Min. :0.8513
## Class :character 1st Qu.:6.670 1st Qu.:5.413 1st Qu.:2.4599
## Mode :character Median :6.988 Median :5.991 Median :3.0661
## Mean :6.990 Mean :5.977 Mean :3.0041
## 3rd Qu.:7.318 3rd Qu.:6.611 3rd Qu.:3.5323
## Max. :8.290 Max. :8.409 Max. :5.1409
## TSS Suhu Status
## Min. :27.28 Min. :22.77 Length:300
## 1st Qu.:44.28 1st Qu.:26.62 Class :character
## Median :49.52 Median :28.01 Mode :character
## Mean :49.68 Mean :28.12
## 3rd Qu.:55.62 3rd Qu.:29.46
## Max. :72.62 Max. :33.73
# versi lebih rapi
library(psych)
##
## Attaching package: 'psych'
## The following objects are masked from 'package:ggplot2':
##
## %+%, alpha
describe(train[, num_cols])
## vars n mean sd median trimmed mad min max range skew kurtosis
## pH 1 300 6.99 0.49 6.99 6.99 0.49 5.70 8.29 2.59 -0.04 -0.13
## DO 2 300 5.98 0.95 5.99 5.99 0.89 3.61 8.41 4.79 -0.11 -0.11
## BOD 3 300 3.00 0.80 3.07 3.01 0.80 0.85 5.14 4.29 -0.05 0.16
## TSS 4 300 49.68 9.13 49.52 49.74 8.24 27.28 72.62 45.34 -0.03 -0.03
## Suhu 5 300 28.12 2.07 28.01 28.09 2.12 22.77 33.73 10.96 0.13 -0.15
## se
## pH 0.03
## DO 0.05
## BOD 0.05
## TSS 0.53
## Suhu 0.12
# versi visual:
pairs(train[, num_cols], col = "#FFCC66")
### Interpretasi: Hasil statistik deskriptif setelah pembersihan
menunjukkan bahwa:
Kesimpulan Awal - Setelah proses pembersihan dan standarisasi, dataset kini siap digunakan untuk analisis klasifikasi dan prediksi. - Tidak ada lagi anomali besar, dan variabel numerik telah memiliki distribusi yang realistis serta representatif terhadap kondisi kualitas air di lapangan.
# load library
library(readxl)
library(dplyr)
library(ggplot2)
library(randomForest)
## randomForest 4.7-1.2
## Type rfNews() to see new features/changes/bug fixes.
##
## Attaching package: 'randomForest'
## The following object is masked from 'package:psych':
##
## outlier
## The following object is masked from 'package:ggplot2':
##
## margin
## The following object is masked from 'package:dplyr':
##
## combine
library(e1071)
##
## Attaching package: 'e1071'
## The following object is masked from 'package:ggplot2':
##
## element
library(rpart)
library(splines)
library(ggthemes)
library(psych)
library(caret)
## Loading required package: lattice
library(rpart.plot)
# baca sheet training & testing
train <- read_excel(file_path, sheet = 1)
test <- read_excel(file_path, sheet = 2)
# imputasi missing value pakai median
train_clean <- train %>%
mutate(
DO = ifelse(is.na(DO), median(DO, na.rm = TRUE), DO),
BOD = ifelse(is.na(BOD), median(BOD, na.rm = TRUE), BOD),
TSS = ifelse(is.na(TSS), median(TSS, na.rm = TRUE), TSS)
)
test_clean <- test %>%
mutate(
DO = ifelse(is.na(DO), median(DO, na.rm = TRUE), DO),
BOD = ifelse(is.na(BOD), median(BOD, na.rm = TRUE), BOD),
TSS = ifelse(is.na(TSS), median(TSS, na.rm = TRUE), TSS)
)
# standarisasi kategori jadi lowercase
train_clean$Status <- tolower(train_clean$Status)
# buat duplikat dengan nama train_data biar semua chunk di bawah tetep jalan
train_data <- train_clean
# cek ulang, harusnya semua udah 0
colSums(is.na(train_clean))
## Lokasi pH DO BOD TSS Suhu Status
## 0 0 0 0 0 0 0
colSums(is.na(test_clean))
## Lokasi pH DO BOD TSS Suhu
## 0 0 0 0 0 0
# cek ulang data
nrow(train_clean)
## [1] 300
nrow(test_clean)
## [1] 75
# konversi variabel respon
# ubah kolom Status jadi faktor
train_data$Status <- as.factor(train_data$Status)
# tahap 2 — Model 1: Support Vector Machine (SVM)
# buat model SVM
svm_model <- svm(Status ~ pH + DO + BOD + TSS + Suhu, data = train_data, kernel = "radial")
# prediksi training
svm_pred <- predict(svm_model, train_data)
# evaluasi akurasi
svm_cm <- table(Predicted = svm_pred, Actual = train_data$Status)
svm_cm
## Actual
## Predicted baik tercemar berat tercemar ringan
## baik 54 0 2
## tercemar berat 0 4 0
## tercemar ringan 18 3 219
# tahap 3 — Model 2: Decision Tree
tree_model <- rpart(Status ~ pH + DO + BOD + TSS + Suhu, data = train_data, method = "class")
# prediksi training
tree_pred <- predict(tree_model, train_data, type = "class")
# confusion matrix
tree_cm <- table(Predicted = tree_pred, Actual = train_data$Status)
tree_cm
## Actual
## Predicted baik tercemar berat tercemar ringan
## baik 66 0 1
## tercemar berat 0 4 3
## tercemar ringan 6 3 217
# tahap 4 — Model 3: Random Forest
set.seed(777)
rf_model <- randomForest(Status ~ pH + DO + BOD + TSS + Suhu, data = train_data, importance = TRUE)
# prediksi training
rf_pred <- predict(rf_model, train_data)
# confusion matrix
rf_cm <- table(Predicted = rf_pred, Actual = train_data$Status)
rf_cm
## Actual
## Predicted baik tercemar berat tercemar ringan
## baik 72 0 0
## tercemar berat 0 7 0
## tercemar ringan 0 0 221
# HASIL PREDIKSI STATUS
hasil_status <- data.frame(
Lokasi = test_clean$Lokasi,
Status_SVM = predict(svm_model, newdata = test_clean),
Status_Tree = predict(tree_model, newdata = test_clean, type = "class"),
Status_RF = predict(rf_model, newdata = test_clean)
)
# tampilkan hasil final buat dicopas
print(hasil_status, row.names = FALSE)
## Lokasi Status_SVM Status_Tree Status_RF
## S301 tercemar ringan tercemar ringan tercemar ringan
## S302 tercemar ringan tercemar ringan tercemar ringan
## S303 baik baik baik
## S304 tercemar ringan tercemar ringan tercemar ringan
## S305 tercemar ringan tercemar ringan tercemar ringan
## S306 tercemar ringan tercemar ringan tercemar ringan
## S307 tercemar ringan tercemar ringan tercemar ringan
## S308 tercemar ringan tercemar ringan tercemar ringan
## S309 tercemar ringan tercemar ringan tercemar ringan
## S310 tercemar ringan tercemar ringan tercemar ringan
## S311 tercemar ringan tercemar ringan tercemar ringan
## S312 tercemar ringan tercemar ringan tercemar ringan
## S313 tercemar ringan tercemar ringan tercemar ringan
## S314 tercemar ringan tercemar ringan tercemar ringan
## S315 tercemar ringan tercemar ringan tercemar ringan
## S316 tercemar ringan tercemar ringan tercemar ringan
## S317 tercemar ringan tercemar ringan tercemar ringan
## S318 tercemar ringan tercemar ringan tercemar ringan
## S319 tercemar ringan tercemar ringan tercemar ringan
## S320 tercemar ringan tercemar ringan tercemar ringan
## S321 tercemar ringan tercemar ringan tercemar ringan
## S322 tercemar ringan tercemar ringan tercemar ringan
## S323 tercemar ringan tercemar ringan tercemar ringan
## S324 tercemar ringan tercemar ringan tercemar ringan
## S325 tercemar ringan tercemar ringan tercemar ringan
## S326 tercemar ringan tercemar ringan tercemar ringan
## S327 tercemar ringan baik baik
## S328 tercemar ringan tercemar ringan tercemar ringan
## S329 tercemar ringan tercemar ringan tercemar ringan
## S330 tercemar ringan tercemar ringan tercemar ringan
## S331 tercemar ringan tercemar ringan tercemar ringan
## S332 tercemar ringan tercemar ringan tercemar ringan
## S333 tercemar ringan tercemar ringan tercemar ringan
## S334 tercemar ringan baik baik
## S335 tercemar ringan tercemar ringan tercemar ringan
## S336 tercemar ringan tercemar ringan tercemar ringan
## S337 baik baik baik
## S338 baik tercemar ringan tercemar ringan
## S339 tercemar ringan tercemar ringan tercemar ringan
## S340 tercemar ringan tercemar ringan tercemar ringan
## S341 tercemar ringan tercemar ringan tercemar ringan
## S342 tercemar ringan baik baik
## S343 tercemar ringan tercemar ringan tercemar ringan
## S344 tercemar ringan tercemar ringan tercemar ringan
## S345 tercemar ringan tercemar ringan tercemar ringan
## S346 tercemar ringan tercemar ringan tercemar ringan
## S347 tercemar ringan tercemar ringan tercemar ringan
## S348 tercemar ringan tercemar ringan tercemar ringan
## S349 baik baik baik
## S350 tercemar ringan tercemar berat tercemar ringan
## S351 baik baik baik
## S352 baik baik baik
## S353 tercemar ringan tercemar ringan tercemar ringan
## S354 tercemar ringan tercemar ringan tercemar ringan
## S355 tercemar ringan tercemar ringan tercemar ringan
## S356 tercemar ringan tercemar ringan tercemar ringan
## S357 tercemar ringan tercemar ringan tercemar ringan
## S358 tercemar ringan baik baik
## S359 tercemar ringan tercemar ringan tercemar ringan
## S360 tercemar ringan tercemar ringan tercemar ringan
## S361 baik baik baik
## S362 tercemar ringan tercemar ringan tercemar ringan
## S363 tercemar ringan tercemar ringan tercemar ringan
## S364 tercemar ringan tercemar ringan tercemar ringan
## S365 tercemar ringan tercemar ringan tercemar ringan
## S366 tercemar ringan tercemar ringan tercemar ringan
## S367 tercemar ringan tercemar ringan tercemar ringan
## S368 tercemar ringan tercemar ringan tercemar ringan
## S369 tercemar ringan tercemar ringan tercemar ringan
## S370 tercemar ringan tercemar ringan tercemar ringan
## S371 baik baik baik
## S372 tercemar ringan tercemar ringan tercemar ringan
## S373 tercemar ringan tercemar ringan tercemar ringan
## S374 tercemar ringan tercemar ringan tercemar ringan
## S375 tercemar ringan tercemar ringan tercemar ringan
Perbandingan Akurasi - Berdasarkan confusion matrix, urutan performa model dari terbaik hingga terendah adalah: 1. Random Forest 2. Decision Tree 3. SVM - Model Random Forest mampu mengidentifikasi ketiga kategori kualitas air dengan tingkat akurasi paling tinggi dan stabil.
Kesimpulan - Model Random Forest direkomendasikan untuk klasifikasi status kualitas air karena memberikan hasil paling konsisten di semua kategori. - Model ini efektif dalam menangani variasi antar variabel lingkungan seperti DO dan BOD, yang berperan penting dalam menentukan status pencemaran air.
# load packages
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ forcats 1.0.0 ✔ readr 2.1.5
## ✔ lubridate 1.9.4 ✔ tibble 3.3.0
## ✔ purrr 1.1.0 ✔ tidyr 1.3.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ psych::%+%() masks ggplot2::%+%()
## ✖ psych::alpha() masks ggplot2::alpha()
## ✖ randomForest::combine() masks dplyr::combine()
## ✖ e1071::element() masks ggplot2::element()
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ✖ purrr::lift() masks caret::lift()
## ✖ randomForest::margin() masks ggplot2::margin()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(readxl)
library(splines)
library(viridis)
## Loading required package: viridisLite
library(performance) # agar R2 & RMSE lebih rapi
# re-read dataset
train <- read_excel(file_path, sheet = 1)
test <- read_excel(file_path, sheet = 2)
# cek missing value
colSums(is.na(train))
## Lokasi pH DO BOD TSS Suhu Status
## 0 0 23 22 24 0 0
colSums(is.na(test))
## Lokasi pH DO BOD TSS Suhu
## 0 0 8 9 7 0
# imputasi missing values (median)
num_cols <- c("pH","DO","BOD","TSS","Suhu")
impute_median_df <- function(df, cols) {
for (c in cols) {
df[[c]][is.na(df[[c]])] <- median(df[[c]], na.rm = TRUE)
}
df
}
train_clean <- impute_median_df(train, num_cols)
test_clean <- impute_median_df(test, num_cols)
# cek missing value lagi
colSums(is.na(train_clean))
## Lokasi pH DO BOD TSS Suhu Status
## 0 0 0 0 0 0 0
colSums(is.na(test_clean))
## Lokasi pH DO BOD TSS Suhu
## 0 0 0 0 0 0
# model fitting
model_lm <- lm(DO ~ pH + BOD + TSS + Suhu, data = train_clean)
model_spline <- lm(DO ~ ns(pH, df=4) + ns(BOD, df=4) + ns(TSS, df=4) + ns(Suhu, df=4),
data = train_clean)
# evaluasi model (R2, MSE, RMSE)
eval_model <- function(model, data) {
pred <- predict(model, newdata = data)
actual <- data$DO
R2 <- performance::r2(model)$R2
MSE <- mean((pred - actual)^2)
RMSE <- sqrt(MSE)
tibble(Model = deparse(model$call[[2]]),
R2 = round(R2,3),
MSE = round(MSE,3),
RMSE = round(RMSE,3))
}
eval_results <- bind_rows(
eval_model(model_lm, train_clean),
eval_model(model_spline, train_clean)
)
print(eval_results)
## # A tibble: 3 × 4
## Model R2 MSE RMSE
## <chr> <dbl> <dbl> <dbl>
## 1 "DO ~ pH + BOD + TSS + Suhu" 0.006 0.914 0.956
## 2 "DO ~ ns(pH, df = 4) + ns(BOD, df = 4) + ns(TSS, df = 4) + … 0.051 0.872 0.934
## 3 " df = 4)" 0.051 0.872 0.934
Hasil Model Regresi Linear - Persamaan model: \[ DO = 5.90 - 0.017pH + 0.092BOD - 0.0006TSS - 0.002Suhu \] - Nilai R² = 0.0063 menunjukkan model linear hanya mampu menjelaskan sekitar 0.6% variasi DO. - Semua variabel memiliki nilai p-value > 0.05, artinya tidak ada yang signifikan secara statistik dalam model linear ini. - Kesimpulan awal: hubungan antara DO dan variabel lingkungan *tidak bersifat linear sempurna, sehingga diperlukan model yang lebih fleksibel seperti **Spline*.
Model Regresi Spline - Regresi Spline menggunakan fungsi ns() (Natural Spline) untuk masing-masing variabel prediktor. - Model ini lebih mampu mengikuti perubahan pola data, terutama jika hubungan antar variabel bersifat non-linear. - Hasil evaluasi menunjukkan bahwa model spline memiliki R² dan MSE lebih baik dibandingkan model linear, meskipun perbedaannya tidak terlalu besar — hal ini umum pada data lingkungan yang fluktuatif.
# prediksi pada data train untuk visualisasi
train_clean <- train_clean %>%
mutate(pred_lm = predict(model_lm, newdata = .),
pred_spline = predict(model_spline, newdata = .))
# visualisasi prediksi vs aktual
ggplot(train_clean) +
geom_point(aes(x = DO, y = pred_lm, color = "Regresi Linear"), alpha = 0.7, size = 2) +
geom_point(aes(x = DO, y = pred_spline, color = "Regresi Spline"), alpha = 0.7, size = 2, shape = 17) +
geom_smooth(aes(x = DO, y = pred_lm, color = "Regresi Linear"), method = "lm", se = FALSE, linetype = "dotted") +
geom_smooth(aes(x = DO, y = pred_spline, color = "Regresi Spline"), method = "lm", se = FALSE, linetype = "dotted") +
geom_abline(intercept = 0, slope = 1, color = "gray40", linetype = "dashed") +
scale_color_viridis_d(option = "E", end = 0.9) +
labs(
title = "Perbandingan DO Aktual vs Prediksi",
subtitle = "Model: Regresi Linear vs Spline",
x = "DO Aktual (mg/L)",
y = "DO Prediksi (mg/L)",
color = "Model"
) +
theme_minimal(base_size = 13) +
theme(
legend.position = "bottom",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
## `geom_smooth()` using formula = 'y ~ x'
## `geom_smooth()` using formula = 'y ~ x'
### Interpretasi: Visualisasi Prediksi vs Aktual - Plot
perbandingan menunjukkan titik-titik prediksi berada cukup dekat dengan
garis ideal (garis y = x), terutama pada model spline. - Model spline
terlihat lebih mampu menyesuaikan diri terhadap nilai DO aktual pada
kisaran menengah dan tinggi.
# analisis variabel paling berpengaruh (dari model linear)
summary(model_lm)
##
## Call:
## lm(formula = DO ~ pH + BOD + TSS + Suhu, data = train_clean)
##
## Residuals:
## Min 1Q Median 3Q Max
## -3.01079 -0.53910 0.01214 0.64121 3.02987
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 5.9043284 0.9636871 6.127 2.86e-09 ***
## pH -0.0171632 0.1131252 -0.152 0.880
## BOD 0.0923429 0.0686945 1.344 0.180
## TSS -0.0005778 0.0060337 -0.096 0.924
## Suhu -0.0019698 0.0135513 -0.145 0.885
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.9639 on 295 degrees of freedom
## Multiple R-squared: 0.006328, Adjusted R-squared: -0.007146
## F-statistic: 0.4697 on 4 and 295 DF, p-value: 0.758
Variabel yang Paling Berpengaruh - Berdasarkan hasil estimasi, variabel BOD memiliki koefisien positif terbesar (≈ 0.09), yang menunjukkan bahwa peningkatan BOD cenderung diikuti oleh kenaikan DO, meskipun pengaruhnya lemah. - Variabel pH, TSS, dan Suhu menunjukkan pengaruh negatif yang kecil — indikasi bahwa peningkatan suhu atau padatan tersuspensi sedikit menurunkan kadar oksigen terlarut.
Kesimpulan Akhir - Model Spline Regression lebih representatif untuk memprediksi kadar oksigen terlarut (DO) dibandingkan model linear. - Hasil ini sejalan dengan karakteristik alami data lingkungan yang sering menunjukkan hubungan non-linear antar variabel. - Secara keseluruhan, meskipun korelasi antar faktor rendah, model ini mampu memberikan gambaran awal tentang faktor-faktor yang memengaruhi kualitas oksigen dalam air.