library(MASS)
library(ggplot2)
library(dplyr)
##
## Attaching package: 'dplyr'
## The following object is masked from 'package:MASS':
##
## select
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(caret)
## Warning: package 'caret' was built under R version 4.5.3
## Loading required package: lattice
library(brant)
## Warning: package 'brant' was built under R version 4.5.3
library(tidyr)
library(scales)
library(knitr)
library(kableExtra)
##
## Attaching package: 'kableExtra'
## The following object is masked from 'package:dplyr':
##
## group_rows
library(gridExtra)
##
## Attaching package: 'gridExtra'
## The following object is masked from 'package:dplyr':
##
## combine
df <- read.csv("C:/Users/ASUS/Downloads/Sleep_health_and_lifestyle_dataset.csv", stringsAsFactors = FALSE)
cat("Dimensi dataset:", nrow(df), "baris x", ncol(df), "kolom\n")
## Dimensi dataset: 374 baris x 13 kolom
str(df)
## 'data.frame': 374 obs. of 13 variables:
## $ Person.ID : int 1 2 3 4 5 6 7 8 9 10 ...
## $ Gender : chr "Male" "Male" "Male" "Male" ...
## $ Age : int 27 28 28 28 28 28 29 29 29 29 ...
## $ Occupation : chr "Software Engineer" "Doctor" "Doctor" "Sales Representative" ...
## $ Sleep.Duration : num 6.1 6.2 6.2 5.9 5.9 5.9 6.3 7.8 7.8 7.8 ...
## $ Quality.of.Sleep : int 6 6 6 4 4 4 6 7 7 7 ...
## $ Physical.Activity.Level: int 42 60 60 30 30 30 40 75 75 75 ...
## $ Stress.Level : int 6 8 8 8 8 8 7 6 6 6 ...
## $ BMI.Category : chr "Overweight" "Normal" "Normal" "Obese" ...
## $ Blood.Pressure : chr "126/83" "125/80" "125/80" "140/90" ...
## $ Heart.Rate : int 77 75 75 85 85 85 82 70 70 70 ...
## $ Daily.Steps : int 4200 10000 10000 3000 3000 3000 3500 8000 8000 8000 ...
## $ Sleep.Disorder : chr "None" "None" "None" "Sleep Apnea" ...
kable(head(df, 6), caption = "Enam Baris Pertama Dataset") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
| Person.ID | Gender | Age | Occupation | Sleep.Duration | Quality.of.Sleep | Physical.Activity.Level | Stress.Level | BMI.Category | Blood.Pressure | Heart.Rate | Daily.Steps | Sleep.Disorder |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Male | 27 | Software Engineer | 6.1 | 6 | 42 | 6 | Overweight | 126/83 | 77 | 4200 | None |
| 2 | Male | 28 | Doctor | 6.2 | 6 | 60 | 8 | Normal | 125/80 | 75 | 10000 | None |
| 3 | Male | 28 | Doctor | 6.2 | 6 | 60 | 8 | Normal | 125/80 | 75 | 10000 | None |
| 4 | Male | 28 | Sales Representative | 5.9 | 4 | 30 | 8 | Obese | 140/90 | 85 | 3000 | Sleep Apnea |
| 5 | Male | 28 | Sales Representative | 5.9 | 4 | 30 | 8 | Obese | 140/90 | 85 | 3000 | Sleep Apnea |
| 6 | Male | 28 | Software Engineer | 5.9 | 4 | 30 | 8 | Obese | 140/90 | 85 | 3000 | Insomnia |
summary(df)
## Person.ID Gender Age Occupation
## Min. : 1.00 Length:374 Min. :27.00 Length:374
## 1st Qu.: 94.25 Class :character 1st Qu.:35.25 Class :character
## Median :187.50 Mode :character Median :43.00 Mode :character
## Mean :187.50 Mean :42.18
## 3rd Qu.:280.75 3rd Qu.:50.00
## Max. :374.00 Max. :59.00
## Sleep.Duration Quality.of.Sleep Physical.Activity.Level Stress.Level
## Min. :5.800 Min. :4.000 Min. :30.00 Min. :3.000
## 1st Qu.:6.400 1st Qu.:6.000 1st Qu.:45.00 1st Qu.:4.000
## Median :7.200 Median :7.000 Median :60.00 Median :5.000
## Mean :7.132 Mean :7.313 Mean :59.17 Mean :5.385
## 3rd Qu.:7.800 3rd Qu.:8.000 3rd Qu.:75.00 3rd Qu.:7.000
## Max. :8.500 Max. :9.000 Max. :90.00 Max. :8.000
## BMI.Category Blood.Pressure Heart.Rate Daily.Steps
## Length:374 Length:374 Min. :65.00 Min. : 3000
## Class :character Class :character 1st Qu.:68.00 1st Qu.: 5600
## Mode :character Mode :character Median :70.00 Median : 7000
## Mean :70.17 Mean : 6817
## 3rd Qu.:72.00 3rd Qu.: 8000
## Max. :86.00 Max. :10000
## Sleep.Disorder
## Length:374
## Class :character
## Mode :character
##
##
##
ringkasan awal dari seluruh dataset yang berisi 374 observasi. Dari sini kita bisa melihat gambaran umum setiap variabel. Variabel seperti Gender, Occupation, BMI.Category, Blood.Pressure, dan Sleep.Disorder bertipe karakter (teks), sehingga hanya ditampilkan panjang datanya saja. Sementara variabel numerik seperti Age, Sleep.Duration, Heart.Rate, dan lainnya ditampilkan lengkap dengan nilai minimum, kuartil, median, mean, dan maksimumnya.
num_cols <- df %>% select(where(is.numeric))
ringkasan_df <- data.frame(
Variabel = names(num_cols),
Min = sapply(num_cols, min, na.rm = TRUE),
Q1 = sapply(num_cols, quantile, 0.25, na.rm = TRUE),
Median = sapply(num_cols, median, na.rm = TRUE),
Mean = round(sapply(num_cols, mean, na.rm = TRUE), 2),
Q3 = sapply(num_cols, quantile, 0.75, na.rm = TRUE),
Max = sapply(num_cols, max, na.rm = TRUE)
)
kable(ringkasan_df, caption = "Ringkasan Statistik Variabel Numerik", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| Variabel | Min | Q1 | Median | Mean | Q3 | Max |
|---|---|---|---|---|---|---|
| Person.ID | 1.0 | 94.25 | 187.5 | 187.50 | 280.75 | 374.0 |
| Age | 27.0 | 35.25 | 43.0 | 42.18 | 50.00 | 59.0 |
| Sleep.Duration | 5.8 | 6.40 | 7.2 | 7.13 | 7.80 | 8.5 |
| Quality.of.Sleep | 4.0 | 6.00 | 7.0 | 7.31 | 8.00 | 9.0 |
| Physical.Activity.Level | 30.0 | 45.00 | 60.0 | 59.17 | 75.00 | 90.0 |
| Stress.Level | 3.0 | 4.00 | 5.0 | 5.39 | 7.00 | 8.0 |
| Heart.Rate | 65.0 | 68.00 | 70.0 | 70.17 | 72.00 | 86.0 |
| Daily.Steps | 3000.0 | 5600.00 | 7000.0 | 6816.84 | 8000.00 | 10000.0 |
Tabel ini merangkum statistik deskriptif untuk semua variabel numerik secara lebih rapi. Age memiliki rentang usia antara 27 hingga 59 tahun dengan rata-rata 42.18 tahun. Ini menunjukkan bahwa responden dalam dataset ini adalah orang dewasa usia produktif hingga paruh baya. Sleep Duration berkisar antara 5.8 hingga 8.5 jam per malam dengan rata-rata 7.13 jam. Rata-rata ini masuk dalam rentang yang direkomendasikan untuk orang dewasa yaitu 7-9 jam. Quality of Sleep memiliki skala 4 hingga 9 dengan median 7 dan mean 7.31, menunjukkan bahwa sebagian besar responden melaporkan kualitas tidur yang cukup baik secara subjektif. Physical Activity Level berkisar antara 30 hingga 90 dengan rata-rata 59.17. Nilai ini merepresentasikan tingkat aktivitas fisik harian responden. Stress Level berada di skala 3 hingga 8 dengan rata-rata 5.39, menunjukkan bahwa rata-rata responden mengalami tingkat stres sedang. Heart Rate berkisar antara 65 hingga 86 bpm dengan rata-rata 70.17 bpm, yang masih dalam batas normal untuk orang dewasa. Daily Steps berkisar antara 3.000 hingga 10.000 langkah per hari dengan rata-rata sekitar 6.817 langkah, sedikit di bawah rekomendasi umum 10.000 langkah per hari.
missing_df <- data.frame(
Kolom = names(df),
Jumlah_Missing = sapply(df, function(x) sum(is.na(x))),
Persentase = round(sapply(df, function(x) mean(is.na(x)) * 100), 2)
)
kable(missing_df, caption = "Nilai Hilang per Kolom", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) %>%
row_spec(which(missing_df$Jumlah_Missing > 0), background = "#fff3cd")
| Kolom | Jumlah_Missing | Persentase |
|---|---|---|
| Person.ID | 0 | 0 |
| Gender | 0 | 0 |
| Age | 0 | 0 |
| Occupation | 0 | 0 |
| Sleep.Duration | 0 | 0 |
| Quality.of.Sleep | 0 | 0 |
| Physical.Activity.Level | 0 | 0 |
| Stress.Level | 0 | 0 |
| BMI.Category | 0 | 0 |
| Blood.Pressure | 0 | 0 |
| Heart.Rate | 0 | 0 |
| Daily.Steps | 0 | 0 |
| Sleep.Disorder | 0 | 0 |
Tabel ini menunjukkan jumlah missing value (data yang kosong) untuk setiap kolom.
ggplot(missing_df, aes(x = reorder(Kolom, Jumlah_Missing),
y = Jumlah_Missing, fill = Jumlah_Missing > 0)) +
geom_col(show.legend = FALSE) +
coord_flip() +
scale_fill_manual(values = c("steelblue","tomato")) +
labs(title = "Jumlah Nilai Hilang per Kolom", x = "Kolom", y = "Jumlah Missing") +
theme_minimal(base_size = 12)
df <- df %>%
mutate(
Kualitas_Tidur = case_when(
Quality.of.Sleep <= 5 ~ "Rendah",
Quality.of.Sleep <= 7 ~ "Sedang",
TRUE ~ "Tinggi"
),
Kualitas_Tidur = factor(Kualitas_Tidur,
levels = c("Rendah","Sedang","Tinggi"),
ordered = TRUE)
)
cat("Distribusi Kualitas Tidur:\n"); print(table(df$Kualitas_Tidur))
## Distribusi Kualitas Tidur:
##
## Rendah Sedang Tinggi
## 12 182 180
cat("\nProporsi (%):\n"); print(round(prop.table(table(df$Kualitas_Tidur)) * 100, 2))
##
## Proporsi (%):
##
## Rendah Sedang Tinggi
## 3.21 48.66 48.13
freq_target <- as.data.frame(table(df$Kualitas_Tidur)) %>%
rename(Kategori = Var1, Frekuensi = Freq) %>%
mutate(Persen = round(Frekuensi / sum(Frekuensi) * 100, 1))
ggplot(freq_target, aes(x = Kategori, y = Frekuensi, fill = Kategori)) +
geom_col(width = 0.6, show.legend = FALSE) +
geom_text(aes(label = paste0(Frekuensi, "\n(", Persen, "%)")), vjust = -0.3, size = 4.5) +
scale_fill_manual(values = c("Rendah"="#e74c3c","Sedang"="#f39c12","Tinggi"="#2ecc71")) +
labs(title = "Distribusi Kualitas Tidur (Variabel Target Ordinal)",
subtitle = "Sleep Health & Lifestyle Dataset",
x = "Kategori Kualitas Tidur", y = "Frekuensi") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"))
numerik_long <- df %>%
select(Age, Sleep.Duration, Physical.Activity.Level,
Stress.Level, Heart.Rate, Daily.Steps) %>%
pivot_longer(everything(), names_to = "Variabel", values_to = "Nilai")
ggplot(numerik_long, aes(x = Nilai, fill = Variabel)) +
geom_histogram(bins = 20, color = "white", alpha = 0.85, show.legend = FALSE) +
facet_wrap(~ Variabel, scales = "free", ncol = 3) +
scale_fill_brewer(palette = "Set2") +
labs(title = "Distribusi Variabel Numerik Prediktor", x = "Nilai", y = "Frekuensi") +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(face = "bold"))
Grafik ini menampilkan histogram distribusi untuk keenam variabel numerik prediktor. Age memiliki distribusi yang cukup menyebar merata dari usia 27 hingga 59 tahun, dengan sedikit puncak di sekitar usia 40-an. Tidak ada pola yang sangat condong ke satu sisi. Daily Steps menunjukkan distribusi yang sangat terkonsentrasi di beberapa nilai tertentu, terutama di sekitar 8.000 langkah. Ini menunjukkan bahwa data langkah harian kemungkinan diisi dalam angka bulat atau kategorikal, bukan pengukuran yang benar-benar kontinu. Heart Rate sebagian besar terkonsentrasi di antara 65-75 bpm, dengan mayoritas responden berada di rentang detak jantung normal. Ada beberapa outlier di atas 80 bpm namun jumlahnya sangat sedikit. Physical Activity Level menunjukkan pola yang unik — terdapat beberapa puncak yang terpisah-pisah di nilai 30, 45, 60, 75, dan 90. Ini sangat mirip dengan data yang dikategorikan atau diisi dalam kelipatan tertentu, bukan pengukuran kontinu. Sleep Duration memiliki distribusi yang lebih menyebar dan mendekati normal, dengan sebagian besar nilai berada antara 6.5 hingga 8 jam. Stress Level juga menunjukkan pola terpisah-pisah di nilai 3, 4, 5, 6, 7, dan 8, yang menandakan bahwa variabel ini sebenarnya bersifat ordinal atau diskret, bukan benar-benar kontinu.
p1 <- ggplot(df, aes(x = BMI.Category, fill = Kualitas_Tidur)) +
geom_bar(position = "fill") +
scale_y_continuous(labels = percent_format()) +
scale_fill_manual(values = c("Rendah"="#e74c3c","Sedang"="#f39c12","Tinggi"="#2ecc71")) +
labs(title = "BMI vs Kualitas Tidur", x = "BMI Category",
y = "Proporsi", fill = "Kualitas Tidur") +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 20, hjust = 1))
p2 <- ggplot(df, aes(x = Gender, fill = Kualitas_Tidur)) +
geom_bar(position = "fill") +
scale_y_continuous(labels = percent_format()) +
scale_fill_manual(values = c("Rendah"="#e74c3c","Sedang"="#f39c12","Tinggi"="#2ecc71")) +
labs(title = "Gender vs Kualitas Tidur", x = "Gender",
y = "Proporsi", fill = "Kualitas Tidur") +
theme_minimal(base_size = 11)
grid.arrange(p1, p2, ncol = 2)
Grafik kiri menunjukkan bahwa orang dengan BMI Normal dan Normal Weight memiliki proporsi kualitas tidur Tinggi yang jauh lebih besar dibanding mereka yang Obese dan Overweight. Orang dengan obesitas dan kelebihan berat badan didominasi oleh kualitas tidur Sedang bahkan ada proporsi Rendah yang cukup besar pada kategori Obese. Ini mengindikasikan bahwa berat badan berlebih berhubungan negatif dengan kualitas tidur. Grafik kanan menunjukkan bahwa perempuan (Female) memiliki proporsi kualitas tidur Tinggi yang lebih besar yaitu sekitar 57%, dibandingkan laki-laki (Male) yang hanya sekitar 39%. Namun perlu dicatat bahwa perbedaan ini bisa juga dipengaruhi oleh faktor lain seperti pekerjaan atau aktivitas fisik yang berbeda antar gender dalam dataset ini, sehingga tidak bisa langsung disimpulkan bahwa gender secara langsung menyebabkan perbedaan kualitas tidur.
kolom_penting <- c("Age","Sleep.Duration","Physical.Activity.Level",
"Stress.Level","Heart.Rate","Daily.Steps",
"BMI.Category","Gender","Kualitas_Tidur")
cat("Pengecekan kolom penting:\n")
## Pengecekan kolom penting:
for (kol in kolom_penting) {
ada <- kol %in% names(df)
cat(sprintf(" %-35s : %s\n", kol, ifelse(ada, "Ada", "Tidak Ada")))
}
## Age : Ada
## Sleep.Duration : Ada
## Physical.Activity.Level : Ada
## Stress.Level : Ada
## Heart.Rate : Ada
## Daily.Steps : Ada
## BMI.Category : Ada
## Gender : Ada
## Kualitas_Tidur : Ada
df_model <- df %>%
select(all_of(kolom_penting)) %>%
mutate(BMI.Category = as.factor(BMI.Category), Gender = as.factor(Gender)) %>%
drop_na()
cat("\nJumlah observasi untuk pemodelan:", nrow(df_model), "\n")
##
## Jumlah observasi untuk pemodelan: 374
Setelah eksplorasi data selesai, dilakukan seleksi kolom yang akan digunakan dalam pemodelan. Kolom yang dipilih hanyalah yang dianggap relevan secara teoritis yaitu Age, Sleep Duration, Physical Activity Level, Stress Level, Heart Rate, Daily Steps, BMI Category, Gender, dan Kualitas Tidur. Variabel BMI Category dan Gender kemudian diubah menjadi tipe faktor karena keduanya bersifat kategorikal. Setelah proses ini, total data yang siap dimodelkan tetap 374 observasi karena tidak ada missing value yang perlu dihapus.
set.seed(42)
idx_train <- createDataPartition(df_model$Kualitas_Tidur, p = 0.8, list = FALSE)
data_train <- df_model[ idx_train, ]
data_test <- df_model[-idx_train, ]
cat("Training:", nrow(data_train), "| Testing:", nrow(data_test), "\n")
## Training: 300 | Testing: 74
Data dibagi dengan proporsi 80% untuk training dan 20% untuk testing. Fungsi ini memastikan pembagian dilakukan secara stratifikasi berdasarkan variabel target Kualitas Tidur, sehingga proporsi kelas Rendah, Sedang, dan Tinggi tetap terjaga di kedua subset. Hasilnya adalah 300 data untuk melatih model dan 74 data untuk menguji performa model pada data yang belum pernah dilihat sebelumnya.
polr()cols_scale <- c("Age", "Sleep.Duration", "Physical.Activity.Level",
"Stress.Level", "Heart.Rate", "Daily.Steps")
means <- sapply(data_train[, cols_scale], mean, na.rm = TRUE)
sds <- sapply(data_train[, cols_scale], sd, na.rm = TRUE)
cat("SDs\n"); print(sds)
## SDs
## Age Sleep.Duration Physical.Activity.Level
## 8.6087217 0.7948976 20.5125516
## Stress.Level Heart.Rate Daily.Steps
## 1.7689884 4.2942120 1639.4276335
cols_scale <- cols_scale[sds > 0]
means <- means[cols_scale]
sds <- sds[cols_scale]
data_train_scaled <- data_train
data_test_scaled <- data_test
for (col in cols_scale) {
data_train_scaled[[col]] <- (data_train[[col]] - means[col]) / sds[col]
data_test_scaled[[col]] <- (data_test[[col]] - means[col]) / sds[col]
}
data_train_scaled <- data_train_scaled %>%
filter(if_all(where(is.numeric), ~ is.finite(.x)))
data_test_scaled <- data_test_scaled %>%
filter(if_all(where(is.numeric), ~ is.finite(.x)))
cat("Train rows setelah cleaning:", nrow(data_train_scaled), "\n")
## Train rows setelah cleaning: 300
cat("Test rows setelah cleaning:", nrow(data_test_scaled), "\n")
## Test rows setelah cleaning: 74
data_train_scaled$BMI.Category <- droplevels(data_train_scaled$BMI.Category)
data_train_scaled$Gender <- droplevels(data_train_scaled$Gender)
data_test_scaled$BMI.Category <- droplevels(data_test_scaled$BMI.Category)
data_test_scaled$Gender <- droplevels(data_test_scaled$Gender)
model_olr <- polr(
Kualitas_Tidur ~ Age + Physical.Activity.Level + Heart.Rate + Daily.Steps,
data = data_train_scaled,
Hess = TRUE,
method = "logistic"
)
## Warning: glm.fit: fitted probabilities numerically 0 or 1 occurred
summary(model_olr)
## Call:
## polr(formula = Kualitas_Tidur ~ Age + Physical.Activity.Level +
## Heart.Rate + Daily.Steps, data = data_train_scaled, Hess = TRUE,
## method = "logistic")
##
## Coefficients:
## Value Std. Error t value
## Age 0.5726 0.1747 3.277
## Physical.Activity.Level 2.0255 0.3228 6.276
## Heart.Rate -2.2097 0.2383 -9.274
## Daily.Steps -1.0344 0.2767 -3.739
##
## Intercepts:
## Value Std. Error t value
## Rendah|Sedang -6.3409 0.6909 -9.1781
## Sedang|Tinggi 0.3889 0.1764 2.2040
##
## Residual Deviance: 283.6826
## AIC: 295.6826
Output ini menampilkan hasil estimasi model Regresi Logistik Ordinal yang dibangun menggunakan fungsi polr() dari paket MASS. Model menggunakan empat prediktor yaitu Age, Physical Activity Level, Heart Rate, dan Daily Steps. Bagian Coefficients menunjukkan koefisien masing-masing prediktor. Age bernilai positif (0.5726) artinya semakin tua usia seseorang, kecenderungan berada di kelas kualitas tidur yang lebih tinggi semakin besar. Physical Activity Level juga positif dan paling besar (2.0255), menandakan aktivitas fisik adalah prediktor terkuat. Heart Rate bernilai negatif (-2.2097) dan Daily Steps juga negatif (-1.0344), artinya keduanya cenderung menurunkan peluang berada di kelas tidur yang lebih tinggi. Bagian Intercepts menampilkan dua nilai batas (threshold) antar kelas yaitu Rendah|Sedang sebesar -6.3409 dan Sedang|Tinggi sebesar 0.3889. Nilai ini adalah titik potong yang digunakan model untuk memisahkan satu kelas ke kelas berikutnya dalam skala ordinal. Residual Deviance sebesar 283.68 dan AIC sebesar 295.68 digunakan untuk mengevaluasi kualitas fit model. Nilai AIC yang lebih kecil menandakan model yang lebih baik, dan nilai ini bisa dibandingkan jika ada model alternatif lain.
ctable <- coef(summary(model_olr))
p_val <- pnorm(abs(ctable[, "t value"]), lower.tail = FALSE) * 2
hasil_koef <- cbind(ctable, `p-value` = round(p_val, 4))
kable(as.data.frame(hasil_koef), caption = "Koefisien Model dengan p-value", digits = 4) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) %>%
row_spec(which(p_val < 0.05), background = "#d4edda")
| Value | Std. Error | t value | p-value | |
|---|---|---|---|---|
| Age | 0.5726 | 0.1747 | 3.2775 | 0.0010 |
| Physical.Activity.Level | 2.0255 | 0.3228 | 6.2757 | 0.0000 |
| Heart.Rate | -2.2097 | 0.2383 | -9.2741 | 0.0000 |
| Daily.Steps | -1.0344 | 0.2767 | -3.7388 | 0.0002 |
| Rendah|Sedang | -6.3409 | 0.6909 | -9.1781 | 0.0000 |
| Sedang|Tinggi | 0.3889 | 0.1764 | 2.2040 | 0.0275 |
Tabel ini memperjelas output summary dengan menambahkan kolom p-value untuk setiap prediktor.
Age memiliki p-value 0.0010, artinya usia berpengaruh signifikan terhadap kualitas tidur pada tingkat kepercayaan 99%.
Physical Activity Level memiliki p-value 0.0000, menjadikannya prediktor paling signifikan dalam model. Ini menunjukkan bahwa aktivitas fisik memiliki hubungan yang sangat kuat dengan kualitas tidur.
Heart Rate memiliki p-value 0.0000 dengan koefisien negatif terbesar, menunjukkan bahwa detak jantung yang tinggi sangat signifikan dalam menurunkan kualitas tidur.
Daily Steps memiliki p-value 0.0002, juga sangat signifikan meskipun arah pengaruhnya negatif. Kedua intercept (Rendah|Sedang dan Sedang|Tinggi) juga signifikan secara statistik, yang berarti batas antar kelas ordinal terdefinisi dengan baik dalam model ini.
all_coef <- coef(summary(model_olr))
pred_names <- names(coef(model_olr))
coef_vals <- coef(model_olr)
se_vals <- sqrt(diag(vcov(model_olr))[pred_names])
or_wald <- data.frame(
OR = round(exp(coef_vals), 4),
CI_Lower = round(exp(coef_vals - 1.96 * se_vals), 4),
CI_Upper = round(exp(coef_vals + 1.96 * se_vals), 4)
)
kable(or_wald, caption = "Odds Ratio & CI 95% (Metode Wald)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| OR | CI_Lower | CI_Upper | |
|---|---|---|---|
| Age | 1.7728 | 1.2588 | 2.4967 |
| Physical.Activity.Level | 7.5801 | 4.0266 | 14.2698 |
| Heart.Rate | 0.1097 | 0.0688 | 0.1750 |
| Daily.Steps | 0.3555 | 0.2067 | 0.6113 |
Odds Ratio (OR) memberikan interpretasi yang lebih intuitif dibandingkan koefisien mentah karena langsung menunjukkan perubahan peluang.
Age (OR = 1.7728, CI: 1.2588–2.4967) Setiap kenaikan satu satuan usia, peluang seseorang berada di kategori kualitas tidur yang lebih tinggi meningkat sebesar 1.77 kali. Interval kepercayaan 95%-nya tidak mencakup angka 1, yang mengkonfirmasi bahwa pengaruh ini signifikan secara statistik.
Physical Activity Level (OR = 7.5801, CI: 4.0266–14.2698) Ini adalah OR terbesar dalam model. Setiap kenaikan satu satuan aktivitas fisik, peluang kualitas tidur lebih tinggi meningkat hampir 7.6 kali lipat. Rentang interval kepercayaannya yang lebar (4.03 hingga 14.27) menunjukkan adanya variabilitas estimasi yang cukup besar, namun tetap signifikan karena tidak melewati angka 1.
Heart Rate (OR = 0.1097, CI: 0.0688–0.1750) Karena OR jauh di bawah 1, ini berarti kenaikan detak jantung sangat kuat menurunkan peluang kualitas tidur yang lebih baik. Setiap kenaikan satu satuan detak jantung, peluangnya menjadi hanya sekitar 11% dari sebelumnya.
Daily Steps (OR = 0.3555, CI: 0.2067–0.6113) Setiap kenaikan satu satuan langkah harian, peluang kualitas tidur lebih tinggi menjadi sekitar 36% dari sebelumnya. Pengaruh negatif ini mungkin karena dalam dataset ini langkah yang sangat tinggi berasosiasi dengan pekerjaan yang melelahkan secara fisik.
tryCatch(
print(brant(model_olr)),
error = function(e) cat("Brant test error:", conditionMessage(e), "\n"),
warning = function(w) cat("Brant test warning:", conditionMessage(w), "\n")
)
## Brant test warning: glm.fit: fitted probabilities numerically 0 or 1 occurred
pred_kelas <- predict(model_olr, newdata = data_test_scaled, type = "class")
pred_prob <- predict(model_olr, newdata = data_test_scaled, type = "probs")
hasil_pred <- data.frame(
Aktual = data_test_scaled$Kualitas_Tidur,
Prediksi = pred_kelas,
Prob_Rendah = round(pred_prob[, "Rendah"], 3),
Prob_Sedang = round(pred_prob[, "Sedang"], 3),
Prob_Tinggi = round(pred_prob[, "Tinggi"], 3)
)
kable(head(hasil_pred, 10), caption = "10 Hasil Prediksi Pertama", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| Aktual | Prediksi | Prob_Rendah | Prob_Sedang | Prob_Tinggi |
|---|---|---|---|---|
| Sedang | Sedang | 0.002 | 0.584 | 0.414 |
| Sedang | Sedang | 0.126 | 0.866 | 0.008 |
| Sedang | Sedang | 0.126 | 0.866 | 0.008 |
| Sedang | Sedang | 0.126 | 0.866 | 0.008 |
| Sedang | Sedang | 0.002 | 0.568 | 0.430 |
| Sedang | Sedang | 0.002 | 0.568 | 0.430 |
| Rendah | Sedang | 0.301 | 0.696 | 0.003 |
| Sedang | Sedang | 0.050 | 0.928 | 0.022 |
| Sedang | Sedang | 0.001 | 0.552 | 0.447 |
| Sedang | Sedang | 0.001 | 0.535 | 0.463 |
Dari 10 data tersebut, 9 di antaranya diprediksi dengan benar sebagai Sedang, dan hanya 1 yang salah yaitu baris ke-7 dimana nilai aktualnya Rendah namun model memprediksi Sedang. Hal ini terjadi karena model memberikan probabilitas tertinggi pada kelas Sedang (0.696) dibanding Rendah (0.301), sehingga model memilih Sedang sebagai prediksi akhir. Pola ini konsisten dengan kelemahan model yang sudah diidentifikasi sebelumnya, yaitu model cenderung tidak mengenali kelas Rendah karena jumlah datanya yang sangat sedikit saat training.
cm <- confusionMatrix(pred_kelas, data_test_scaled$Kualitas_Tidur)
print(cm)
## Confusion Matrix and Statistics
##
## Reference
## Prediction Rendah Sedang Tinggi
## Rendah 0 0 0
## Sedang 2 30 7
## Tinggi 0 6 29
##
## Overall Statistics
##
## Accuracy : 0.7973
## 95% CI : (0.6878, 0.8819)
## No Information Rate : 0.4865
## P-Value [Acc > NIR] : 3.721e-08
##
## Kappa : 0.6053
##
## Mcnemar's Test P-Value : NA
##
## Statistics by Class:
##
## Class: Rendah Class: Sedang Class: Tinggi
## Sensitivity 0.00000 0.8333 0.8056
## Specificity 1.00000 0.7632 0.8421
## Pos Pred Value NaN 0.7692 0.8286
## Neg Pred Value 0.97297 0.8286 0.8205
## Prevalence 0.02703 0.4865 0.4865
## Detection Rate 0.00000 0.4054 0.3919
## Detection Prevalence 0.00000 0.5270 0.4730
## Balanced Accuracy 0.50000 0.7982 0.8238
cm_tabel <- as.data.frame(cm$table) %>%
rename(Prediksi = Prediction, Aktual = Reference, Frekuensi = Freq)
ggplot(cm_tabel, aes(x = Aktual, y = Prediksi, fill = Frekuensi)) +
geom_tile(color = "white", linewidth = 0.8) +
geom_text(aes(label = Frekuensi), size = 7, fontface = "bold", color = "white") +
scale_fill_gradient(low = "#aed6f1", high = "#1a5276") +
labs(title = "Confusion Matrix - Regresi Logistik Ordinal",
subtitle = "Prediksi vs Aktual pada Data Testing",
x = "Kategori Aktual", y = "Kategori Prediksi", fill = "Frekuensi") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"))
Confusion matrix menampilkan hasil prediksi model secara menyeluruh pada 74 data testing. Untuk kelas Rendah, dari 2 data yang aktualnya Rendah, tidak ada satupun yang berhasil diprediksi dengan benar, keduanya salah diklasifikasikan sebagai Sedang. Ini karena kelas Rendah hanya ada 2.7% dari total data sehingga model hampir tidak mendapatkan cukup contoh untuk belajar mengenali kelas ini. Untuk kelas Sedang, dari 36 data yang aktualnya Sedang, sebanyak 30 berhasil diprediksi benar dan 6 salah diprediksi sebagai Tinggi. Tingkat kebenaran untuk kelas ini cukup baik yaitu sekitar 83%. Untuk kelas Tinggi, dari 36 data yang aktualnya Tinggi, sebanyak 29 berhasil diprediksi benar dan 7 salah diprediksi sebagai Sedang. Tingkat kebenarannya juga baik yaitu sekitar 81%. Secara keseluruhan model mencapai akurasi 79.73% dengan interval kepercayaan 95% antara 68.78% hingga 88.19%. No Information Rate sebesar 0.4865 adalah akurasi jika model hanya selalu menebak kelas terbanyak, dan model kita jauh melampaui itu. P-Value [Acc > NIR] sebesar 3.721e-08 mengkonfirmasi bahwa performa model secara statistik jauh lebih baik dari tebakan acak. Cohen’s Kappa 0.6053 masuk dalam kategori substantial agreement, artinya kesepakatan antara prediksi dan aktual cukup kuat dan bukan kebetulan.
akurasi <- cm$overall["Accuracy"]
kappa <- cm$overall["Kappa"]
cat(sprintf("Akurasi Model : %.4f (%.2f%%)\n", akurasi, akurasi * 100))
## Akurasi Model : 0.7973 (79.73%)
cat(sprintf("Cohen's Kappa : %.4f\n", kappa))
## Cohen's Kappa : 0.6053
metrik_kelas <- as.data.frame(cm$byClass) %>%
mutate(across(where(is.numeric), ~ round(.x, 4)))
kable(metrik_kelas, caption = "Metrik Evaluasi per Kelas") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
| Sensitivity | Specificity | Pos Pred Value | Neg Pred Value | Precision | Recall | F1 | Prevalence | Detection Rate | Detection Prevalence | Balanced Accuracy | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Class: Rendah | 0.0000 | 1.0000 | NaN | 0.9730 | NA | 0.0000 | NA | 0.0270 | 0.0000 | 0.000 | 0.5000 |
| Class: Sedang | 0.8333 | 0.7632 | 0.7692 | 0.8286 | 0.7692 | 0.8333 | 0.8000 | 0.4865 | 0.4054 | 0.527 | 0.7982 |
| Class: Tinggi | 0.8056 | 0.8421 | 0.8286 | 0.8205 | 0.8286 | 0.8056 | 0.8169 | 0.4865 | 0.3919 | 0.473 | 0.8238 |
metrik_plot <- data.frame(
Kelas = rownames(cm$byClass),
Sensitivity = cm$byClass[, "Sensitivity"],
Specificity = cm$byClass[, "Specificity"],
F1_Score = cm$byClass[, "F1"]
) %>% pivot_longer(-Kelas, names_to = "Metrik", values_to = "Nilai")
ggplot(metrik_plot, aes(x = Kelas, y = Nilai, fill = Metrik)) +
geom_col(position = "dodge", width = 0.65) +
geom_text(aes(label = round(Nilai, 2)),
position = position_dodge(0.65), vjust = -0.4, size = 3.5) +
scale_fill_brewer(palette = "Set2") +
scale_y_continuous(limits = c(0, 1.1), labels = percent_format()) +
labs(title = "Metrik Evaluasi per Kelas",
subtitle = "Sensitivity, Specificity, dan F1-Score",
x = "Kelas", y = "Nilai", fill = "Metrik") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_col()`).
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_text()`).
Kelas Rendah memiliki sensitivity 0% yang berarti model tidak mampu mendeteksi satu pun data Rendah yang benar. Namun specificity-nya mencapai 100% karena model tidak pernah salah memprediksi data lain sebagai Rendah. Precision dan F1-Score tidak bisa dihitung (NA) karena tidak ada prediksi positif untuk kelas ini sama sekali. Kelas Sedang memiliki sensitivity 83.33% artinya dari semua data yang benar-benar Sedang, sebanyak 83% berhasil terdeteksi. Specificity-nya 76.32% artinya 76% dari data yang bukan Sedang berhasil diidentifikasi dengan benar. Precision 76.92% dan F1-Score 0.80 menunjukkan keseimbangan yang baik antara ketepatan dan kelengkapan prediksi. Kelas Tinggi memiliki performa paling seimbang dengan sensitivity 80.56%, specificity 84.21%, precision 82.86%, dan F1-Score 0.8169. Ini adalah kelas yang paling baik diprediksi oleh model secara keseluruhan. Grafik batang (Image 10) memperlihatkan secara visual bahwa kelas Rendah sangat bermasalah dengan F1-Score dan sensitivity di angka 0, sementara kelas Sedang dan Tinggi menunjukkan performa yang relatif seimbang dan konsisten di kisaran 76–84% untuk semua metrik.
##
## Dataset : Sleep Health & Lifestyle Dataset
## Target : Kualitas Tidur (Ordinal: Rendah < Sedang < Tinggi)
## Metode : Proportional Odds Logistic Regression (POLR)
## Paket R : MASS::polr()
##
## Prediktor dalam Model (4 variabel):
## Age, Physical.Activity.Level, Heart.Rate, Daily.Steps
##
## Prediktor Dikeluarkan:
## Sleep.Duration -> perfect separation
## Stress.Level -> perfect separation
## BMI.Category -> quasi-complete separation
##
## Ukuran Data
## Training : 300 observasi (80%)
## Testing : 74 observasi (20%)
##
## Hasil Evaluasi
## Akurasi : 79.73%
## Cohen's Kappa : 0.6053
Dataset: Sleep Health and Lifestyle - Kaggle