Pendahuluan

Latar Belakang

Sektor transportasi merupakan salah satu sektor strategis yang menopang aktivitas perekonomian nasional. Perusahaan-perusahaan di sektor ini berperan penting dalam distribusi barang, jasa, dan mobilitas masyarakat. Namun, sektor ini juga rentan terhadap tekanan keuangan akibat tingginya kebutuhan modal, fluktuasi harga bahan bakar, dan volatilitas nilai tukar.

Financial distress β€” kondisi penurunan kinerja keuangan yang dapat berujung kebangkrutan β€” merupakan ancaman nyata yang perlu dideteksi secara dini. Platt & Platt (2002) mendefinisikannya sebagai proses akhir dari penurunan kinerja sebelum perusahaan mengalami kebangkrutan.

Rumusan Masalah

  1. Apakah indikator keuangan (current ratio, debt ratio, ROA, sales growth, firm size) secara simultan berpengaruh terhadap probabilitas financial distress?
  2. Variabel prediktor manakah yang secara parsial berpengaruh signifikan terhadap status financial distress?
  3. Seberapa baik kemampuan model dalam memprediksi status financial distress pada data pengujian?

Tujuan

  1. Membangun model regresi logistik biner untuk memprediksi probabilitas financial distress.
  2. Mengidentifikasi variabel prediktor yang berpengaruh signifikan pada taraf Ξ± = 0,05.
  3. Mengevaluasi kinerja model melalui metrik akurasi, sensitivitas, spesifisitas, dan AUC.

1. Persiapan

1.1 Paket yang Digunakan

πŸ”§ Kode R
required_packages <- c("dplyr", "ggplot2", "broom", "knitr", "kableExtra",
                       "scales", "readr", "tidyr", "patchwork")
missing_packages <- required_packages[
  !vapply(required_packages, requireNamespace, logical(1), quietly = TRUE)
]

if (length(missing_packages) > 0) {
  stop(
    "Paket berikut belum tersedia: ",
    paste(missing_packages, collapse = ", "),
    ". Silakan install terlebih dahulu."
  )
}

invisible(lapply(required_packages, library, character.only = TRUE))
cat("βœ… Semua paket berhasil dimuat:", paste(required_packages, collapse = ", "))
βœ… Semua paket berhasil dimuat: dplyr, ggplot2, broom, knitr, kableExtra, scales, readr, tidyr, patchwork
πŸ’¬ Penjelasan Kode

Kode di atas memuat 9 paket yang diperlukan selama analisis. Pemeriksaan awal (requireNamespace) memastikan tidak ada paket yang hilang sebelum analisis dimulai β€” jika ada paket yang belum terinstall, proses akan berhenti dengan pesan informatif.

1.2 Import Data

πŸ”§ Kode R
raw_data <- readr::read_csv("data projek adk bismillah-3.csv",
                        show_col_types = FALSE)

# Periksa dimensi awal
ringkasan_data <- data.frame(
  Keterangan = c("Jumlah observasi", "Jumlah variabel"),
  Nilai      = c(nrow(raw_data), ncol(raw_data)-5)
)

knitr::kable(
  ringkasan_data,
  caption = "Ukuran dataset Financial Distress"
)
Ukuran dataset Financial Distress
Keterangan Nilai
Jumlah observasi 120
Jumlah variabel 5
πŸ’¬ Interpretasi Import Data

Data berhasil dimuat dari file CSV. Dataset terdiri dari 120 observasi (panel data: 20 perusahaan Γ— 6 tahun, 2019–2024) dengan sejumlah variabel yang mencakup rasio keuangan dan status financial distress perusahaan sektor transportasi di BEI.


2. Eksplorasi Data

2.1 Kamus Variabel

πŸ”§ Kode R
kamus_variabel <- data.frame(
  `Kode`     = c("X₁", "Xβ‚‚", "X₃", "Xβ‚„", "Xβ‚…", "Y"),
  `Variabel` = c("Current Ratio", "Debt Ratio", "Return on Assets (ROA)",
                 "Sales Growth", "Firm Size", "Financial Distress"),
  `Definisi` = c(
    "Aset Lancar / Kewajiban Lancar β€” mengukur kemampuan memenuhi kewajiban jangka pendek",
    "Total Utang / Total Aset β€” mengukur proporsi aset yang dibiayai utang",
    "Laba Bersih / Total Aset β€” mengukur efisiensi menghasilkan laba dari aset",
    "(Penjualan_t - Penjualan_{t-1}) / Penjualan_{t-1} β€” pertumbuhan penjualan tahunan",
    "ln(Total Aset) β€” proksi ukuran perusahaan, mereduksi skewness",
    "0 = Non-distress, 1 = Distress (berdasarkan nilai EPS negatif)"
  ),
  `Tipe` = c("Numerik","Numerik","Numerik","Numerik","Numerik","Respon Biner"),
  check.names = FALSE
)

knitr::kable(kamus_variabel,
             caption = "Kamus variabel dataset Financial Distress") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::column_spec(1, bold = TRUE, width = "3em") |>
  kableExtra::column_spec(3, width = "35em")
Kamus variabel dataset Financial Distress
Kode Variabel Definisi Tipe
X₁ Current Ratio Aset Lancar / Kewajiban Lancar β€” mengukur kemampuan memenuhi kewajiban jangka pendek Numerik
Xβ‚‚ Debt Ratio Total Utang / Total Aset β€” mengukur proporsi aset yang dibiayai utang Numerik
X₃ Return on Assets (ROA) Laba Bersih / Total Aset β€” mengukur efisiensi menghasilkan laba dari aset Numerik
Xβ‚„ Sales Growth (Penjualan_t - Penjualan_{t-1}) / Penjualan_{t-1} β€” pertumbuhan penjualan tahunan Numerik
Xβ‚… Firm Size ln(Total Aset) β€” proksi ukuran perusahaan, mereduksi skewness Numerik
Y Financial Distress 0 = Non-distress, 1 = Distress (berdasarkan nilai EPS negatif) Respon Biner
πŸ’¬ Interpretasi Kamus Variabel

Model menggunakan 5 variabel prediktor numerik (X₁–Xβ‚…) dan 1 variabel respon biner (Y). Pemilihan variabel didasarkan pada kajian literatur terkait indikator keuangan yang relevan untuk mendeteksi financial distress, mencakup aspek likuiditas (CR), leverage (DR), profitabilitas (ROA), pertumbuhan (SG), dan ukuran perusahaan (FS).

2.2 Pembersihan & Penamaan Ulang Kolom

πŸ”§ Kode R
fd <- raw_data %>%
  transmute(
    kode_saham         = `Kode Saham`,
    nama_perusahaan    = `Nama Perusahaan`,
    tahun              = Tahun,
    current_ratio      = `Current Ratio\n(X1)`,
    debt_ratio         = `Debt Ratio\n(X2)`,
    roa                = `ROA\n(X3)`,
    sales_growth       = `Sales Growth\n(X4)`,
    firm_size          = `Firm Size\n(X5)\n=LN(Total Aset)`,
    financial_distress = factor(
      `Financial Distress\n(Y)\n0=Non | 1=Distress`,
      levels = c(0, 1),
      labels = c("Non-distress", "Distress")
    ),
    distress_int = as.integer(`Financial Distress\n(Y)\n0=Non | 1=Distress`)
  ) %>%
  na.omit()

cat("βœ… Data berhasil dibersihkan:", nrow(fd), "observasi dari",
    n_distinct(fd$kode_saham), "perusahaan.\n")
βœ… Data berhasil dibersihkan: 120 observasi dari 20 perusahaan.
cat("   Observasi dihapus (NA):", nrow(raw_data) - nrow(fd))
   Observasi dihapus (NA): 0
πŸ’¬ Interpretasi Pembersihan Data

Tahap ini melakukan rename kolom dari nama panjang dengan karakter khusus menjadi nama bersih yang R-friendly. Variabel respon diubah menjadi factor berlabel (β€œNon-distress” / β€œDistress”) untuk analisis, sekaligus tetap disimpan sebagai integer (0/1) untuk keperluan pemodelan. Observasi dengan nilai hilang (NA) dihapus menggunakan na.omit().

2.3 Contoh Data

πŸ”§ Kode R
fd %>%
  transmute(
    `Kode Saham`    = kode_saham,
    Tahun           = tahun,
    `Current Ratio` = round(current_ratio, 3),
    `Debt Ratio`    = round(debt_ratio, 3),
    ROA             = round(roa, 3),
    `Sales Growth`  = round(sales_growth, 3),
    `Firm Size`     = round(firm_size, 2),
    Status          = financial_distress
  ) %>%
  head(10) %>%
  knitr::kable(caption = "Sepuluh baris pertama data setelah pembersihan") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(which(fd$financial_distress[1:10] == "Distress"),
                       background = "#fff0ee")
Sepuluh baris pertama data setelah pembersihan
Kode Saham Tahun Current Ratio Debt Ratio ROA Sales Growth Firm Size Status
CMPP 2019 0.471 0.923 -0.060 0.585 7.87 Distress
CMPP 2020 0.035 1.479 -0.453 -0.760 8.71 Distress
CMPP 2021 0.025 2.011 -0.454 -0.611 8.55 Distress
CMPP 2022 0.038 2.272 -0.307 5.040 8.59 Distress
CMPP 2023 0.039 1.445 -0.177 0.752 8.72 Distress
CMPP 2024 0.047 1.641 -0.267 0.199 8.65 Distress
GIAA 2019 0.348 0.838 0.001 0.038 11.03 Non-distress
GIAA 2020 0.125 1.180 -0.237 -0.664 11.93 Non-distress
GIAA 2021 0.053 1.849 -0.582 -0.121 11.54 Distress
GIAA 2022 0.477 1.246 0.572 0.633 11.48 Non-distress
πŸ’¬ Interpretasi Contoh Data

Baris yang diarsir merah muda menunjukkan perusahaan berstatus Distress. Dari cuplikan ini terlihat variasi yang cukup lebar pada rasio keuangan antar perusahaan β€” perbedaan yang diharapkan mampu ditangkap oleh model regresi logistik.

2.4 Statistik Deskriptif

πŸ”§ Kode R
vars_num   <- c("current_ratio","debt_ratio","roa","sales_growth","firm_size")
label_vars <- c("Current Ratio","Debt Ratio","ROA","Sales Growth","Firm Size")

desc_list <- lapply(seq_along(vars_num), function(i) {
  v <- vars_num[i]
  nd <- fd[[v]][fd$financial_distress == "Non-distress"]
  ds <- fd[[v]][fd$financial_distress == "Distress"]
  data.frame(
    Variabel              = label_vars[i],
    `Non-distress Mean`   = round(mean(nd),  3),
    `Non-distress Median` = round(median(nd),3),
    `Non-distress SD`     = round(sd(nd),    3),
    `Distress Mean`       = round(mean(ds),  3),
    `Distress Median`     = round(median(ds),3),
    `Distress SD`         = round(sd(ds),    3),
    check.names = FALSE
  )
})
desc_tidy <- do.call(rbind, desc_list)

knitr::kable(desc_tidy,
             caption = "Statistik deskriptif berdasarkan status Financial Distress") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::add_header_above(c(" " = 1,
                                  "Non-distress (n=91)" = 3,
                                  "Distress (n=29)" = 3))
Statistik deskriptif berdasarkan status Financial Distress
Non-distress (n=91)
Distress (n=29)
Variabel Non-distress Mean Non-distress Median Non-distress SD Distress Mean Distress Median Distress SD
Current Ratio 1.730 1.256 1.875 2.697 0.580 10.146
Debt Ratio 0.557 0.525 0.307 0.860 0.577 0.794
ROA 0.072 0.021 0.242 -0.153 -0.060 0.170
Sales Growth 0.127 0.082 0.352 0.134 -0.025 1.016
Firm Size 7.080 6.342 2.060 6.606 5.714 1.853
πŸ’¬ Interpretasi Statistik Deskriptif

Perbandingan rata-rata kedua kelompok mengungkap pola yang bermakna:

Variabel Non-distress Distress Keterangan
ROA +0,072 βˆ’0,153 ⚠️ Perbedaan paling mencolok β€” ROA negatif = rugi
Debt Ratio 0,557 0,860 Distress lebih bergantung pada utang
Firm Size 7,080 6,606 Distress cenderung perusahaan lebih kecil
Sales Growth 0,134 0,127 Hampir sama β€” bukan pembeda utama
Current Ratio Median 1,256 Median 0,580 Median jauh berbeda, namun rata-rata distors oleh outlier

ROA menonjol sebagai pembeda terkuat antar kelompok, mengindikasikan bahwa profitabilitas adalah sinyal utama financial distress.

2.5 Distribusi Kelas Respon

πŸ”§ Kode R
class_summary <- fd %>%
  count(financial_distress, name = "Jumlah") %>%
  mutate(Proporsi = scales::percent(Jumlah / sum(Jumlah), accuracy = 0.1)) %>%
  rename(`Status Financial Distress` = financial_distress)

knitr::kable(class_summary,
             caption = "Distribusi kelas respon Financial Distress",
             align   = c("l","c","c")) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE)
Distribusi kelas respon Financial Distress
Status Financial Distress Jumlah Proporsi
Non-distress 91 75.8%
Distress 29 24.2%
⚠️ Catatan Ketidakseimbangan Kelas

Dari 120 observasi, 75,8% adalah Non-distress dan hanya 24,2% Distress. Ketidakseimbangan ini perlu diperhatikan dalam evaluasi model β€” akurasi saja tidak cukup sebagai metrik tunggal. Model yang selalu memprediksi β€œNon-distress” pun bisa mencapai akurasi 75,8%, sehingga metrik seperti sensitivity, F1-score, dan balanced accuracy menjadi lebih relevan.

ggplot(fd, aes(x = financial_distress, fill = financial_distress)) +
  geom_bar(width = 0.5, color = "white", linewidth = 0.8) +
  geom_text(stat = "count",
            aes(label = paste0(after_stat(count), "\n(",
                               scales::percent(after_stat(count)/nrow(fd), accuracy = 0.1),")")),
            vjust = -0.3, fontface = "bold", size = 4.5) +
  scale_fill_manual(values = c("Non-distress" = "#2a9d8f", "Distress" = "#e76f51")) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(title = "Distribusi Status Financial Distress",
       subtitle = "20 perusahaan transportasi BEI, 2019–2024 (n = 120)",
       x = NULL, y = "Jumlah observasi") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", size = 14))

2.6 Visualisasi Hubungan Prediktor dengan Respon

πŸ”§ Kode R
p1 <- ggplot(fd, aes(x = roa, y = debt_ratio, color = financial_distress)) +
  geom_point(alpha = 0.75, size = 2.5) +
  scale_color_manual(values = c("Non-distress" = "#2a9d8f", "Distress" = "#e76f51")) +
  labs(title = "ROA vs. Debt Ratio", x = "ROA (X₃)", y = "Debt Ratio (Xβ‚‚)",
       color = "Status") +
  theme_minimal(base_size = 11) +
  theme(legend.position = "none", plot.title = element_text(face = "bold", size = 11))

p2 <- ggplot(fd, aes(x = financial_distress, y = roa, fill = financial_distress)) +
  geom_boxplot(alpha = 0.7, outlier.shape = 21, outlier.size = 2.5) +
  scale_fill_manual(values = c("Non-distress" = "#2a9d8f", "Distress" = "#e76f51")) +
  labs(title = "Distribusi ROA per Kelas", x = NULL, y = "ROA (X₃)") +
  theme_minimal(base_size = 11) +
  theme(legend.position = "none", plot.title = element_text(face = "bold", size = 11))

p3 <- ggplot(fd, aes(x = financial_distress, y = debt_ratio, fill = financial_distress)) +
  geom_boxplot(alpha = 0.7, outlier.shape = 21, outlier.size = 2.5) +
  scale_fill_manual(values = c("Non-distress" = "#2a9d8f", "Distress" = "#e76f51")) +
  labs(title = "Distribusi Debt Ratio per Kelas", x = NULL, y = "Debt Ratio (Xβ‚‚)") +
  theme_minimal(base_size = 11) +
  theme(legend.position = "none", plot.title = element_text(face = "bold", size = 11))

(p1 | p2 | p3) +
  plot_annotation(
    title   = "Eksplorasi Prediktor vs. Status Financial Distress",
    caption = "Warna hijau = Non-distress  ●  Warna oranye = Distress",
    theme   = theme(plot.title = element_text(face = "bold", size = 13))
  )

πŸ’¬ Interpretasi Visualisasi Prediktor
  • Scatter plot ROA vs.Β Debt Ratio: Titik oranye (distress) terkonsentrasi di kuadran kiri dengan ROA rendah/negatif, menunjukkan pola pemisahan yang cukup baik.
  • Boxplot ROA: Median kelompok distress jauh lebih rendah (bahkan negatif), memperkuat dugaan ROA sebagai prediktor paling diskriminatif.
  • Boxplot Debt Ratio: Kelompok distress memiliki rentang nilai yang lebih bervariasi dan median lebih tinggi, namun terdapat tumpang tindih yang cukup besar dengan kelompok sehat.

3. Pembagian Data Training & Testing

πŸ”§ Kode R
set.seed(42)

# Fungsi stratified split: menjaga proporsi kelas di kedua subset
stratified_split <- function(y, prop = 0.8) {
  idx_by_class <- split(seq_along(y), y)
  train_idx <- lapply(idx_by_class,
                      function(idx) sample(idx, size = floor(length(idx) * prop)))
  unlist(train_idx, use.names = FALSE)
}

train_id   <- stratified_split(fd$distress_int, prop = 0.8)
train_data <- fd[ train_id, ]
test_data  <- fd[-train_id, ]

cat(sprintf("βœ… Training: %d observasi | Testing: %d observasi\n",
            nrow(train_data), nrow(test_data)))
βœ… Training: 95 observasi | Testing: 25 observasi
cat(sprintf("   Rasio split: %.0f%% : %.0f%%\n",
            nrow(train_data)/nrow(fd)*100, nrow(test_data)/nrow(fd)*100))
   Rasio split: 79% : 21%
split_summary <- bind_rows(
  train_data %>% count(financial_distress) %>% mutate(data = "Training"),
  test_data  %>% count(financial_distress) %>% mutate(data = "Testing")
) %>%
  group_by(data) %>%
  mutate(proporsi = scales::percent(n / sum(n), accuracy = 0.1)) %>%
  ungroup() %>%
  rename(Data = data, Status = financial_distress, Jumlah = n, Proporsi = proporsi)

knitr::kable(split_summary,
             caption = "Distribusi kelas pada data training dan testing (stratified split 80:20)") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::pack_rows("Training", 1, 2) |>
  kableExtra::pack_rows("Testing", 3, 4)
Distribusi kelas pada data training dan testing (stratified split 80:20)
Status Jumlah Data Proporsi
Training
Non-distress 72 Training 75.8%
Distress 23 Training 24.2%
Testing
Non-distress 19 Testing 76.0%
Distress 6 Testing 24.0%
πŸ’¬ Interpretasi Pembagian Data

Data dibagi dengan rasio 80:20 menggunakan stratified split β€” memastikan proporsi kelas Distress (Β±24%) terjaga sama di kedua subset. Ini krusial untuk mencegah bias estimasi parameter dan agar performa pada data testing representatif.

<div class="split-num">95</div>
<div class="split-lbl">Data Training</div>
<div class="split-detail">72 Non-distress + 23 Distress</div>
<div class="split-num">25</div>
<div class="split-lbl">Data Testing</div>
<div class="split-detail">19 Non-distress + 6 Distress</div>

4. Estimasi Model Regresi Logistik Biner

4.1 Konsep Dasar

Model regresi logistik biner memodelkan probabilitas kondisional:

\[\pi(\mathbf{x}) = P(Y = 1 \mid \mathbf{X} = \mathbf{x}) = \frac{\exp(\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p)}{1 + \exp(\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p)}\]

Transformasi logit mengubah rentang probabilitas \((0,1)\) menjadi \((-\infty, +\infty)\):

\[\text{logit}[\pi(\mathbf{x})] = \ln\!\left[\frac{\pi(\mathbf{x})}{1 - \pi(\mathbf{x})}\right] = \beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p\]

Parameter diestimasi menggunakan Maximum Likelihood Estimation (MLE), yang memaksimalkan:

\[\ell(\boldsymbol{\beta}) = \sum_{i=1}^{n} \left\{ y_i \ln[\pi(\mathbf{x}_i)] + (1-y_i)\ln[1 - \pi(\mathbf{x}_i)] \right\}\]

4.2 Estimasi Model Penuh

πŸ”§ Kode R
# Membangun model regresi logistik biner dengan semua prediktor
fd_fit <- glm(
  distress_int ~ current_ratio + debt_ratio + roa + sales_growth + firm_size,
  data   = train_data,
  family = binomial(link = "logit")   # link = "logit" adalah default
)

# Ringkasan kecocokan model
ringkasan_model <- data.frame(
  Keterangan = c("Jumlah observasi training", "Null deviance",
                 "Residual deviance", "Penurunan deviance",
                 "Derajat bebas residual", "AIC", "McFadden RΒ²"),
  Nilai = c(
    nobs(fd_fit),
    round(fd_fit$null.deviance, 3),
    round(fd_fit$deviance, 3),
    round(fd_fit$null.deviance - fd_fit$deviance, 3),
    fd_fit$df.residual,
    round(AIC(fd_fit), 3),
    round(1 - (fd_fit$deviance / fd_fit$null.deviance), 4)
  )
)

knitr::kable(ringkasan_model,
             caption = "Ringkasan kecocokan model regresi logistik biner",
             align   = c("l","r")) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::row_spec(c(4, 7), bold = TRUE, background = "#f0f9f8")
Ringkasan kecocokan model regresi logistik biner
Keterangan Nilai
Jumlah observasi training 95.0000
Null deviance 105.1640
Residual deviance 66.8470
Penurunan deviance 38.3170
Derajat bebas residual 89.0000
AIC 78.8470
McFadden RΒ² 0.3644
πŸ’¬ Interpretasi Ringkasan Model
  • Null deviance (105,164): Deviance model tanpa prediktor (hanya intersep) β€” mereflesikan ketidaksesuaian awal sebelum variabel prediktor dimasukkan.
  • Residual deviance (66,847): Deviance setelah kelima prediktor dimasukkan. Penurunan yang cukup besar menandakan prediktor berkontribusi signifikan.
  • Penurunan deviance = 38,317: Digunakan sebagai statistik uji Likelihood Ratio Test (lihat Bagian 4.3).
  • AIC = 78,847: Digunakan untuk perbandingan model; semakin kecil semakin baik. AIC menyertakan penalti kompleksitas model.
  • McFadden RΒ² = 0,364: Nilai di atas 0,20 sudah dianggap baik dalam regresi logistik. Ini menunjukkan model menjelaskan ~36,4% variansi dalam log-odds.

4.3 Uji Signifikansi Simultan (Likelihood Ratio Test)

Hipotesis:

\[H_0: \beta_1 = \beta_2 = \beta_3 = \beta_4 = \beta_5 = 0 \quad \text{vs} \quad H_1: \text{minimal ada satu } \beta_j \neq 0\]

Statistik uji:

\[G = D_{\text{null}} - D_{\text{residual}} = 105{,}164 - 66{,}847 = 38{,}317 \sim \chi^2_{(5)}\]

πŸ”§ Kode R
# Menghitung statistik G (Likelihood Ratio Test)
G_stat  <- fd_fit$null.deviance - fd_fit$deviance
df_lrt  <- fd_fit$df.null - fd_fit$df.residual
p_lrt   <- pchisq(G_stat, df = df_lrt, lower.tail = FALSE)

lrt_table <- data.frame(
  `Statistik G`              = round(G_stat, 3),
  `Derajat Bebas`            = df_lrt,
  `Nilai kritis χ²(0.05, 5)` = round(qchisq(0.95, df = df_lrt), 3),
  `p-value`                  = format.pval(p_lrt, digits = 4, eps = 1e-4),
  `Keputusan`                = ifelse(p_lrt < 0.05, "βœ… Tolak Hβ‚€", "Gagal Tolak Hβ‚€"),
  check.names = FALSE
)

knitr::kable(lrt_table,
             caption = "Hasil Uji Likelihood Ratio Test (uji simultan)",
             align   = c("r","c","r","r","c")) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::row_spec(1, bold = TRUE, background = "#f0f9f8")
Hasil Uji Likelihood Ratio Test (uji simultan)
Statistik G Derajat Bebas Nilai kritis χ²(0.05, 5) p-value Keputusan
38.317 5 11.07 < 1e-04 βœ… Tolak Hβ‚€ |
πŸ“‹ Hasil Uji Simultan

G = 38.317 dengan df = 5 β†’ p-value < 0,001 β†’ Hβ‚€ Ditolak

πŸ’¬ Interpretasi Uji Simultan (LRT)

Nilai statistik \(G = 38{,}317\) dengan 5 derajat bebas menghasilkan p-value < 0,001, jauh di bawah taraf signifikansi \(\alpha = 0{,}05\). Karena \(G = 38{,}317 > \chi^2_{0,05;5} = 11{,}07\), maka \(H_0\) ditolak.

Kesimpulan: Secara simultan, terdapat minimal satu variabel prediktor yang berpengaruh signifikan terhadap probabilitas financial distress. Model dengan kelima prediktor secara nyata lebih baik daripada model null (tanpa prediktor).

4.4 Tabel Koefisien, Odds Ratio & Uji Wald (Parsial)

Uji Wald untuk masing-masing koefisien:

\[H_0: \beta_j = 0 \quad \text{vs} \quad H_1: \beta_j \neq 0, \qquad W_j = \left(\frac{\hat{\beta}_j}{SE(\hat{\beta}_j)}\right)^2 \sim \chi^2_{(1)}\]

πŸ”§ Kode R
# Mengekstrak koefisien, menghitung odds ratio dan confidence interval
coef_table <- broom::tidy(fd_fit) %>%
  mutate(
    odds_ratio = exp(estimate),
    ci_low     = exp(estimate - 1.96 * std.error),
    ci_high    = exp(estimate + 1.96 * std.error),
    wald_stat  = (estimate / std.error)^2
  ) %>%
  arrange(p.value) %>%
  transmute(
    `Variabel`              = term,
    `Ξ²Μ‚ (Koefisien)`        = round(estimate, 4),
    `SE(Ξ²Μ‚)`               = round(std.error, 4),
    `Statistik Wald (zΒ²)`  = round(wald_stat, 3),
    `Odds Ratio (eΞ²Μ‚)`     = round(odds_ratio, 4),
    `SK 95% OR`            = paste0("[", round(ci_low, 3), "; ", round(ci_high, 3), "]"),
    `p-value`              = signif(p.value, 3),
    `Signifikan (Ξ±=0,05)`  = ifelse(p.value < 0.05, "βœ… Ya", "❌ Tidak")
  )

knitr::kable(coef_table,
             caption = "Estimasi koefisien, odds ratio, dan uji signifikansi parsial (Uji Wald)") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(which(coef_table$`Signifikan (Ξ±=0,05)` == "βœ… Ya"),
                       bold = TRUE, background = "#f0f9f8") |>
  kableExtra::column_spec(8, color = ifelse(coef_table$`Signifikan (Ξ±=0,05)` == "βœ… Ya",
                                             "#2a9d8f", "#e76f51"), bold = TRUE)
Estimasi koefisien, odds ratio, dan uji signifikansi parsial (Uji Wald)
Variabel Ξ²Μ‚ (Koefisien
Β SE(Ξ²
roa -16.0189 4.4192 13.139 0.0000 [0; 0.001] 0.000289 βœ… Ya |
sales_growth 1.0455 0.9035 1.339 2.8449 [0.484; 16.715] 0.247000 ❌ Tidak |
current_ratio 0.0462 0.0504 0.842 1.0473 [0.949; 1.156] 0.359000 ❌ Tidak |
firm_size -0.1590 0.1739 0.836 0.8530 [0.607; 1.199] 0.361000 ❌ Tidak |
(Intercept) -0.6364 1.1992 0.282 0.5292 [0.05; 5.552] 0.596000 ❌ Tidak |
debt_ratio -0.2399 0.6681 0.129 0.7867 [0.212; 2.914] 0.720000 ❌ Tidak |
πŸ“‹ Hasil Uji Parsial (Wald)

Dari 5 prediktor, hanya ROA (X₃) yang signifikan pada Ξ± = 0,05 Β· Ξ²Μ‚ = βˆ’16,019 Β· OR β‰ˆ 0 Β· p-value = 0,000289

πŸ’¬ Interpretasi Uji Parsial (Wald)
  • ROA (X₃) β€” signifikan (p = 0,000289):
    • Koefisien Ξ²Μ‚ = βˆ’16,019: tanda negatif menunjukkan bahwa semakin tinggi ROA, semakin rendah log-odds perusahaan mengalami financial distress β€” sesuai ekspektasi teoritis.
    • Odds Ratio β‰ˆ 0: setiap peningkatan 1 satuan ROA akan mengalikan odds distress dengan faktor yang sangat kecil mendekati nol, artinya probabilitas distress turun drastis.
    • SK 95% OR = [0; 0,001]: selang kepercayaan tidak mencakup nilai 1, memperkuat signifikansi ROA.
  • Debt ratio, current ratio, sales growth, firm size β€” tidak signifikan (p > 0,05): Kemungkinan karena karakteristik struktural sektor transportasi dengan leverage tinggi secara merata, sehingga variasi debt ratio tidak cukup diskriminatif antar perusahaan. Meski demikian, arah koefisiennya konsisten secara teoritis.

4.5 Persamaan Model

Berdasarkan hasil estimasi, persamaan model logit adalah:

\[ \ln\!\left(\frac{\hat{p}}{1-\hat{p}}\right) = -0{,}636 -16{,}019\,X_3 +1{,}046\,X_4 +0{,}046\,X_1 -0{,}159\,X_5 -0{,}240\,X_2 \]

Sehingga probabilitas prediksi financial distress adalah:

\[ \hat{p} = \frac{ \exp\!\left( -0{,}636 -16{,}019\,\text{ROA} +1{,}046\,\text{SG} +0{,}046\,\text{CR} -0{,}159\,\text{FS} -0{,}240\,\text{DR} \right) } { 1+ \exp\!\left( -0{,}636 -16{,}019\,\text{ROA} +1{,}046\,\text{SG} +0{,}046\,\text{CR} -0{,}159\,\text{FS} -0{,}240\,\text{DR} \right) } \]

πŸ’‘ Contoh Perhitungan Manual

Perusahaan dengan ROA = 0,05; Debt Ratio = 0,5; Current Ratio = 1,2; Sales Growth = 0,1; Firm Size = 7,0:

\[ \text{logit}(\hat{p}) = -0{,}636 -16{,}019(0{,}05) +1{,}046(0{,}1) +0{,}046(1{,}2) -0{,}159(7{,}0) -0{,}240(0{,}5) = -2{,}548 \]

\[ \hat{p} = \frac{e^{-2{,}548}} {1+e^{-2{,}548}} \approx \mathbf{7{,}3\%} \]

β†’ Probabilitas perusahaan tersebut mengalami financial distress sekitar 7,3%, sehingga perusahaan diklasifikasikan sebagai Non-distress karena probabilitas yang dihasilkan berada di bawah threshold 0,5 (50%).


5. Prediksi & Evaluasi Model

5.1 Prediksi Probabilitas

πŸ”§ Kode R
# Prediksi probabilitas pada data training dan testing
p_train <- predict(fd_fit, newdata = train_data, type = "response")
p_test  <- predict(fd_fit, newdata = test_data,  type = "response")

cat(sprintf("Range probabilitas prediksi (Testing): [%.4f, %.4f]\n",
            min(p_test), max(p_test)))
Range probabilitas prediksi (Testing): [0.0013, 0.9835]
cat(sprintf("Rata-rata prediksi kelas Distress    : %.4f\n",
            mean(p_test[test_data$distress_int == 1])))
Rata-rata prediksi kelas Distress    : 0.5208
cat(sprintf("Rata-rata prediksi kelas Non-distress: %.4f\n",
            mean(p_test[test_data$distress_int == 0])))
Rata-rata prediksi kelas Non-distress: 0.1057
prediction_preview <- data.frame(
  `Kode Saham`            = test_data$kode_saham,
  `Tahun`                 = test_data$tahun,
  `Status Aktual`         = test_data$financial_distress,
  `PΜ‚(Distress)`         = round(p_test, 4),
  `Klasifikasi (thr=0.5)` = ifelse(p_test >= 0.5, "Distress", "Non-distress"),
  check.names = FALSE
) %>%
  arrange(desc(`PΜ‚(Distress)`)) %>%
  head(10)

knitr::kable(prediction_preview,
             caption = "10 observasi testing dengan probabilitas prediksi tertinggi") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(which(prediction_preview$`Status Aktual` == "Distress"),
                       background = "#fff0ee")
10 observasi testing dengan probabilitas prediksi tertinggi
Kode Saham Tahun Status Aktual PΜ‚(Distress |Klasifikasi (thr=0.5)
1 CMPP 2020 Distress 0.9835 Distress
20 SDMU 2020 Distress 0.8859 Distress
9 WEHA 2020 Non-distress 0.5835 Distress
8 LRNA 2021 Distress 0.5781 Distress
15 MIRA 2023 Distress 0.2720 Non-distress
16 MIRA 2024 Distress 0.2498 Non-distress
25 TNCA 2023 Non-distress 0.2224 Non-distress
19 SAPX 2022 Non-distress 0.1782 Non-distress
24 TNCA 2022 Non-distress 0.1774 Non-distress
12 BLTA 2020 Distress 0.1556 Non-distress
πŸ’¬ Interpretasi Prediksi Probabilitas

Tabel diurutkan dari probabilitas prediksi tertinggi ke terendah. Baris yang diarsir merah muda adalah perusahaan yang sesungguhnya Distress. Situasi ideal adalah seluruh baris merah muda berada di posisi teratas dengan nilai PΜ‚ mendekati 1. Perhatikan apakah ada perusahaan distress yang tersembunyi di bawah ambang batas β€” ini disebut false negative.

Output konsol menunjukkan bahwa rata-rata prediksi untuk kelas Distress jauh lebih tinggi dibandingkan Non-distress, menandakan model mampu memisahkan kedua kelompok dengan baik.

5.2 Fungsi Utilitas Evaluasi

πŸ”§ Kode R
# Fungsi pembagi aman (menghindari pembagian dengan nol)
safe_div <- function(num, den) ifelse(den == 0, NA_real_, num / den)

# Fungsi utama: menghitung semua metrik klasifikasi dari actual & probabilitas
classification_metrics <- function(actual, prob, threshold = 0.5) {
  pred <- as.integer(prob >= threshold)
  tp   <- sum(pred == 1 & actual == 1)   # True Positive
  tn   <- sum(pred == 0 & actual == 0)   # True Negative
  fp   <- sum(pred == 1 & actual == 0)   # False Positive
  fn   <- sum(pred == 0 & actual == 1)   # False Negative

  sens  <- safe_div(tp, tp + fn)         # Sensitivity / Recall
  spec  <- safe_div(tn, tn + fp)         # Specificity
  prec  <- safe_div(tp, tp + fp)         # Precision
  npv   <- safe_div(tn, tn + fn)         # Negative Predictive Value
  acc   <- safe_div(tp + tn, tp + tn + fp + fn)   # Accuracy

  data.frame(threshold = threshold, TP = tp, TN = tn, FP = fp, FN = fn,
             accuracy = acc, error_rate = 1 - acc,
             sensitivity = sens, specificity = spec,
             precision = prec, npv = npv,
             f1_score = safe_div(2*prec*sens, prec + sens),
             balanced_accuracy = (sens + spec) / 2,
             fpr = 1 - spec, fnr = 1 - sens)
}

# Fungsi pembantu: format confusion matrix
confusion_matrix_fmt <- function(actual, prob, threshold = 0.5) {
  pred_label   <- factor(ifelse(prob >= threshold, "Prediksi Distress", "Prediksi Non-distress"),
                         levels = c("Prediksi Distress","Prediksi Non-distress"))
  actual_label <- factor(ifelse(actual == 1, "Aktual Distress", "Aktual Non-distress"),
                         levels = c("Aktual Distress","Aktual Non-distress"))
  addmargins(table(actual_label, pred_label))
}

cat("βœ… Fungsi evaluasi (classification_metrics & confusion_matrix_fmt) berhasil didefinisikan.")
βœ… Fungsi evaluasi (classification_metrics & confusion_matrix_fmt) berhasil didefinisikan.
πŸ’¬ Penjelasan Fungsi Evaluasi
Dua fungsi utilitas didefinisikan secara manual (tanpa bergantung paket tambahan):
  • classification_metrics(): menghitung 10+ metrik evaluasi dari vektor aktual dan probabilitas prediksi, termasuk accuracy, sensitivity, specificity, F1-score, dan balanced accuracy.
  • confusion_matrix_fmt(): memformat confusion matrix dengan label yang jelas dan baris/kolom marginal menggunakan addmargins().
  • safe_div(): menangani pembagian dengan nol (ketika suatu kelas tidak muncul sama sekali di prediksi).

5.3 Confusion Matrix & Metrik pada Threshold 0,50

πŸ”§ Kode R
# Evaluasi pada threshold default 0,50
cm_05  <- confusion_matrix_fmt(test_data$distress_int, p_test, 0.5)
met_05 <- classification_metrics(test_data$distress_int, p_test, 0.5)

knitr::kable(cm_05,
             caption = "Confusion matrix data testing β€” threshold = 0,50") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::add_header_above(c(" " = 1, "Prediksi" = 2, " " = 1)) |>
  kableExtra::column_spec(2, background = "#f0f9f8") |>
  kableExtra::column_spec(3, background = "#fff0ee")
Confusion matrix data testing β€” threshold = 0,50
Prediksi
Prediksi Distress Prediksi Non-distress Sum
Aktual Distress 3 3 6
Aktual Non-distress 1 18 19
Sum 4 21 25
metrik_display <- data.frame(
  Metrik  = c("Akurasi","Error Rate","Sensitivity (Recall)",
              "Specificity","Presisi","NPV","F1-Score","Balanced Accuracy","FPR","FNR"),
  Nilai   = round(c(met_05$accuracy, met_05$error_rate, met_05$sensitivity,
                    met_05$specificity, met_05$precision, met_05$npv,
                    met_05$f1_score, met_05$balanced_accuracy,
                    met_05$fpr, met_05$fnr), 3),
  Formula = c("(TP+TN)/N", "1βˆ’Akurasi", "TP/(TP+FN)",
              "TN/(TN+FP)", "TP/(TP+FP)", "TN/(TN+FN)",
              "2Β·PrecΒ·Sens/(Prec+Sens)", "(Sens+Spec)/2", "FP/(FP+TN)", "FN/(FN+TP)")
)

knitr::kable(metrik_display,
             caption = "Metrik evaluasi pada threshold 0,50") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(c(1,3,4), bold = TRUE, background = "#fffde7")
Metrik evaluasi pada threshold 0,50
Metrik Nilai Formula
Akurasi 0.840 (TP+TN)/N
Error Rate 0.160 1βˆ’Akurasi
Sensitivity (Recall) 0.500 TP/(TP+FN)
Specificity 0.947 TN/(TN+FP)
Presisi 0.750 TP/(TP+FP)
NPV 0.857 TN/(TN+FN)
F1-Score 0.600 2Β·PrecΒ·Sens/(Prec+Sens)
Balanced Accuracy 0.724 (Sens+Spec)/2
FPR 0.053 FP/(FP+TN)
FNR 0.500 FN/(FN+TP)
πŸ“‹ Hasil pada Threshold 0,50

Akurasi = 84,0% Β· Sensitivity = 50,0% (3 dari 6 Distress terdeteksi) Β· Specificity = 94,7% Β· F1-Score = 0,600

πŸ’¬ Interpretasi Threshold 0,50
  • Akurasi = 84,0%: 21 dari 25 observasi diklasifikasikan benar.
  • Sensitivity = 50,0%: Model hanya mendeteksi 3 dari 6 kasus distress β€” 3 perusahaan distress lolos tidak terdeteksi (false negative).
  • Specificity = 94,7%: Model sangat baik mengidentifikasi perusahaan sehat β€” hanya 1 kesalahan false positive.
  • F1-Score = 0,600 dan Balanced Accuracy = 72,4% β€” mencerminkan ketidakseimbangan performa antara dua kelas.

⚠️ Masalah Kritis: Dalam konteks sistem peringatan dini, false negative (perusahaan distress yang tidak terdeteksi) jauh lebih berbahaya daripada false positive. Threshold 0,50 terlalu konservatif untuk kelas minoritas ini β€” perlu disesuaikan ke nilai yang lebih rendah.


6. Kurva ROC & Threshold Optimal

6.1 Perhitungan ROC dan AUC

πŸ”§ Kode R
# Fungsi menghitung titik-titik kurva ROC untuk semua threshold
roc_points <- function(actual, prob) {
  thresholds <- c(Inf, sort(unique(prob), decreasing = TRUE), -Inf)
  out <- lapply(thresholds, function(th) {
    pred <- as.integer(prob >= th)
    tp   <- sum(pred == 1 & actual == 1)
    tn   <- sum(pred == 0 & actual == 0)
    fp   <- sum(pred == 1 & actual == 0)
    fn   <- sum(pred == 0 & actual == 1)
    data.frame(threshold = th,
               sensitivity = safe_div(tp, tp + fn),
               specificity = safe_div(tn, tn + fp),
               fpr = 1 - safe_div(tn, tn + fp),
               youden = safe_div(tp, tp+fn) + safe_div(tn, tn+fp) - 1)
  })
  bind_rows(out)
}

# Fungsi menghitung AUC menggunakan metode trapesium
auc_value <- function(roc_df) {
  roc_sorted <- roc_df %>% arrange(fpr, sensitivity)
  sum(diff(roc_sorted$fpr) *
        (head(roc_sorted$sensitivity, -1) + tail(roc_sorted$sensitivity, -1)) / 2)
}

# Hitung ROC untuk training dan testing
roc_train <- roc_points(train_data$distress_int, p_train) %>% mutate(data = "Training")
roc_test  <- roc_points(test_data$distress_int,  p_test)  %>% mutate(data = "Testing")

auc_train <- auc_value(roc_train)
auc_test  <- auc_value(roc_test)

# Threshold optimal: memaksimalkan Indeks Youden J = Sensitivity + Specificity - 1
optimal_train <- roc_train %>%
  filter(is.finite(threshold)) %>%
  arrange(desc(youden), desc(sensitivity)) %>%
  slice(1)

threshold_opt <- optimal_train$threshold[1]

test_at_opt <- roc_test %>%
  filter(is.finite(threshold)) %>%
  slice_min(abs(threshold - threshold_opt), n = 1, with_ties = FALSE) %>%
  mutate(data = "Testing (threshold opt)")

cat(sprintf("AUC Training : %.4f\n", auc_train))
AUC Training : 0.9124
cat(sprintf("AUC Testing  : %.4f\n", auc_test))
AUC Testing  : 0.9386
cat(sprintf("Threshold opt: %.4f (Indeks Youden J = %.4f)\n",
            threshold_opt, optimal_train$youden))
Threshold opt: 0.2657 (Indeks Youden J = 0.7723)
auc_table <- data.frame(
  Data = c("Training","Testing"),
  AUC  = round(c(auc_train, auc_test), 3),
  Interpretasi = c(
    ifelse(auc_train > 0.9, "🌟 Sangat baik (> 0,9)", "βœ… Baik (0,8–0,9)"),
    ifelse(auc_test  > 0.9, "🌟 Sangat baik (> 0,9)", "βœ… Baik (0,8–0,9)")
  )
)

threshold_table <- optimal_train %>%
  transmute(
    `Threshold optimal` = round(threshold, 3),
    Sensitivity         = round(sensitivity, 3),
    Specificity         = round(specificity, 3),
    `Indeks Youden (J)` = round(youden, 3)
  )

knitr::kable(auc_table,
             caption = "Nilai AUC pada data training dan testing") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::row_spec(1:2, bold = TRUE, background = "#f0f9f8")
Nilai AUC pada data training dan testing
Data AUC Interpretasi
Training 0.912 🌟 Sangat baik (> 0,9) |
Testing 0.939 🌟 Sangat baik (> 0,9) |
knitr::kable(threshold_table,
             caption = "Threshold optimal berdasarkan Indeks Youden (dari kurva ROC training)") |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::row_spec(1, bold = TRUE, background = "#fff8e1")
Threshold optimal berdasarkan Indeks Youden (dari kurva ROC training)
Threshold optimal Sensitivity Specificity Indeks Youden (J)
0.266 0.87 0.903 0.772
πŸ“‹ Hasil AUC & Threshold Optimal

AUC Training = 0.912 Β· AUC Testing = 0.939 Β· Threshold Optimal = 0.266 (Youden J = 0.772)

πŸ’¬ Interpretasi AUC dan Threshold Optimal
  • AUC training = 0.912 dan AUC testing = 0.939 β€” keduanya melampaui ambang β€œsangat baik” (> 0,9), menunjukkan kemampuan diskriminasi yang sangat kuat.
  • Nilai AUC testing tidak lebih rendah dari training mengindikasikan tidak ada overfitting β€” model dapat digeneralisasikan dengan baik ke data baru.
  • Threshold optimal = 0.266 (dari Indeks Youden, \(J = \text{Sensitivity} + \text{Specificity} - 1\)): Model mengklasifikasikan perusahaan sebagai distress jika probabilitas \(\geq\) 0.266, jauh lebih rendah dari threshold default 0,50.

6.2 Plot Kurva ROC

πŸ”§ Kode R
roc_plot <- bind_rows(roc_train, roc_test)

ggplot(roc_plot, aes(x = fpr, y = sensitivity, color = data)) +
  geom_path(linewidth = 1.3) +
  geom_abline(intercept = 0, slope = 1, linetype = "dashed",
              color = "#6c757d", linewidth = 0.8) +
  # Titik optimal training
  geom_point(data = optimal_train,
             aes(x = fpr, y = sensitivity),
             inherit.aes = FALSE,
             color = "#ffb703", fill = "#fb8500",
             shape = 21, size = 5.5, stroke = 1.5) +
  # Titik testing pada threshold optimal
  geom_point(data = test_at_opt,
             aes(x = fpr, y = sensitivity),
             inherit.aes = FALSE,
             color = "#8338ec", fill = "#3a86ff",
             shape = 24, size = 4.5, stroke = 1.5) +
  annotate("text", x = optimal_train$fpr + 0.05, y = optimal_train$sensitivity - 0.05,
           label = paste0("Opt. Training\n(thr=", round(threshold_opt,3),
                          ", J=", round(optimal_train$youden,3),")"),
           size = 3.4, color = "#fb8500", hjust = 0, fontface = "bold") +
  annotate("text", x = test_at_opt$fpr + 0.05, y = test_at_opt$sensitivity + 0.04,
           label = paste0("Testing\n(Sens=", round(test_at_opt$sensitivity,3),
                          ", Spec=", round(test_at_opt$specificity,3),")"),
           size = 3.4, color = "#8338ec", hjust = 0, fontface = "bold") +
  coord_equal(xlim = c(0,1), ylim = c(0,1)) +
  scale_color_manual(values = c("Training" = "#0077b6", "Testing" = "#e76f51")) +
  labs(title    = "Kurva ROC β€” Model Regresi Logistik Biner",
       subtitle = paste0("AUC Training = ", round(auc_train,3),
                         "  |  AUC Testing = ", round(auc_test,3),
                         "  |  Threshold Optimal = ", round(threshold_opt,3)),
       x        = "False Positive Rate (1 βˆ’ Specificity)",
       y        = "True Positive Rate (Sensitivity)",
       color    = "Data") +
  theme_minimal(base_size = 12) +
  theme(legend.position  = "bottom",
        plot.title       = element_text(face = "bold", size = 13),
        plot.subtitle    = element_text(color = "#555", size = 11))

πŸ’¬ Interpretasi Kurva ROC
  • Kedua kurva (training dan testing) berada jauh di atas garis diagonal (klasifikasi acak), mengkonfirmasi kemampuan diskriminasi yang tinggi.
  • Titik optimal (⬀ oranye): Koordinat pada kurva ROC training yang memaksimalkan Indeks Youden β€” ini adalah threshold yang merekomendasikan trade-off terbaik antara sensitivity dan specificity.
  • Titik testing (β–² ungu): Menunjukkan posisi model pada data testing saat menggunakan threshold optimal yang sama β€” performa konsisten dengan training.
  • Garis putus-putus abu-abu = classifier acak (AUC = 0,5). Semakin jauh kurva dari garis ini, semakin baik model.

7. Perbandingan Threshold & Distribusi Probabilitas

7.1 Metrik pada Dua Threshold

πŸ”§ Kode R
# Evaluasi pada threshold optimal
met_opt <- classification_metrics(test_data$distress_int, p_test, threshold_opt)

# Tabel perbandingan kedua threshold
compare_df <- bind_rows(
  data.frame(Threshold = 0.50,
             Akurasi = met_05$accuracy,    `Error Rate` = met_05$error_rate,
             Sensitivity = met_05$sensitivity, Specificity = met_05$specificity,
             Presisi = met_05$precision,   NPV = met_05$npv,
             `F1-Score` = met_05$f1_score, `Balanced Acc.` = met_05$balanced_accuracy,
             check.names = FALSE),
  data.frame(Threshold = round(threshold_opt, 3),
             Akurasi = met_opt$accuracy,    `Error Rate` = met_opt$error_rate,
             Sensitivity = met_opt$sensitivity, Specificity = met_opt$specificity,
             Presisi = met_opt$precision,   NPV = met_opt$npv,
             `F1-Score` = met_opt$f1_score, `Balanced Acc.` = met_opt$balanced_accuracy,
             check.names = FALSE)
) %>%
  mutate(across(where(is.numeric), round, 3))

knitr::kable(compare_df,
             caption = "Perbandingan metrik evaluasi pada dua threshold",
             align   = c("c", rep("r", ncol(compare_df)-1))) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(2, bold = TRUE, background = "#f0f9f8") |>
  kableExtra::add_header_above(c(" "=1, "Kinerja Klasifikasi"=8))
Perbandingan metrik evaluasi pada dua threshold
Kinerja Klasifikasi
Threshold Akurasi Error Rate Sensitivity Specificity Presisi NPV F1-Score Balanced Acc.
0.500 0.84 0.16 0.500 0.947 0.75 0.857 0.600 0.724
0.266 0.88 0.12 0.667 0.947 0.80 0.900 0.727 0.807
# Confusion matrix pada threshold optimal
cm_opt <- confusion_matrix_fmt(test_data$distress_int, p_test, threshold_opt)

knitr::kable(cm_opt,
             caption = paste0("Confusion matrix data testing β€” threshold optimal (",
                              round(threshold_opt,3), ")")) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover"),
                            full_width = FALSE) |>
  kableExtra::add_header_above(c(" " = 1, "Prediksi" = 2, " " = 1))
Confusion matrix data testing β€” threshold optimal (0.266)
Prediksi
Prediksi Distress Prediksi Non-distress Sum
Aktual Distress 4 2 6
Aktual Non-distress 1 18 19
Sum 5 20 25
πŸ“‹ Perbandingan Ringkas

Threshold 0,50 β†’ Sensitivity 50% | Threshold 0.266 β†’ Sensitivity 66,7% (deteksi 4/6 kasus Distress) Β· Specificity tetap 94,7% Β· Balanced Accuracy naik dari 72,4% β†’ 80,7%

πŸ’¬ Interpretasi Perbandingan Threshold

Penggunaan threshold optimal menghasilkan peningkatan performa konsisten:

Metrik Threshold 0,50 Threshold 0.266 Ξ”
Akurasi 84,0% 88,0% ↑ +4,0%
Sensitivity 50,0% 66,7% ↑ +16,7%
Specificity 94,7% 94,7% β†’ sama
F1-Score 0,600 0,727 ↑ +0,127
Balanced Accuracy 72,4% 80,7% ↑ +8,3%

Peningkatan terbesar pada Sensitivity (+16,7%): model kini mendeteksi 4 dari 6 perusahaan distress β€” tanpa mengorbankan Specificity sama sekali. Threshold 0.266 lebih direkomendasikan untuk sistem peringatan dini (early warning system).

7.2 Distribusi Probabilitas Prediksi

πŸ”§ Kode R
test_prob_plot <- test_data %>% mutate(peluang_distress = p_test)

ggplot(test_prob_plot, aes(x = peluang_distress, fill = financial_distress)) +
  geom_density(alpha = 0.55, color = "white", linewidth = 0.7) +
  geom_rug(aes(color = financial_distress), alpha = 0.8, linewidth = 1) +
  geom_vline(xintercept = threshold_opt, color = "#fb8500",
             linewidth = 1.3, linetype = "dashed") +
  geom_vline(xintercept = 0.5, color = "#adb5bd",
             linewidth = 0.9, linetype = "dotted") +
  annotate("label", x = threshold_opt, y = Inf,
           label = paste0("Opt. = ", round(threshold_opt,3)),
           vjust = 1.4, fill = "#fff3b0", color = "#5f370e",
           label.size = 0, fontface = "bold") +
  annotate("label", x = 0.5, y = Inf,
           label = "Default = 0,50",
           vjust = 3.0, fill = "#f8f9fa", color = "#6c757d",
           label.size = 0) +
  scale_fill_manual(values  = c("Non-distress" = "#2a9d8f","Distress" = "#e76f51")) +
  scale_color_manual(values = c("Non-distress" = "#2a9d8f","Distress" = "#e76f51"),
                     guide  = "none") +
  labs(title    = "Distribusi Probabilitas Prediksi Financial Distress (Data Testing)",
       subtitle = "Garis putus-putus oranye = threshold optimal Β· Titik di bawah = nilai individu",
       x        = "Probabilitas prediksi PΜ‚(Distress)",
       y        = "Kepadatan",
       fill     = "Status aktual") +
  theme_minimal(base_size = 12) +
  theme(legend.position  = "bottom",
        plot.title       = element_text(face = "bold", size = 13))

πŸ’¬ Interpretasi Distribusi Probabilitas
  • Non-distress (hijau): Distribusi terkonsentrasi tajam mendekati 0 β€” model secara konsisten memberi skor risiko rendah bagi perusahaan sehat. Ini ditandai dengan kurva yang sempit dan tinggi di sisi kiri.
  • Distress (oranye-merah): Distribusi lebih datar dan menyebar β€” beberapa perusahaan distress mendapat skor tinggi (terdeteksi baik), namun ada yang berada di bawah threshold (false negative).
  • Tumpang tindih (overlap) di rentang 0,00–0,27 adalah sumber utama false negative. Dengan threshold optimal 0,266, batas klasifikasi digeser ke kiri sehingga lebih banyak perusahaan distress tertangkap.
  • Titik-titik (rug) di bawah menunjukkan nilai prediksi individual β€” setiap titik adalah satu perusahaan.

8. Ringkasan & Kesimpulan

8.1 Ringkasan Hasil

πŸ”§ Kode R
ringkasan_final <- data.frame(
  Aspek = c(
    "Metode", "Data",
    "Variabel signifikan (Ξ±=0,05)",
    "Uji simultan (LRT)", "McFadden RΒ²",
    "AUC Training", "AUC Testing",
    "Threshold optimal (Youden)",
    "Akurasi (thr. optimal)",
    "Sensitivity (thr. optimal)",
    "Specificity (thr. optimal)",
    "Balanced Accuracy (thr. optimal)"
  ),
  Hasil = c(
    "Regresi Logistik Biner (MLE)",
    "120 obs., 20 perusahaan transportasi BEI 2019–2024 | Training 95 / Testing 25",
    paste0("ROA (X₃) β€” p-value = 0,000289; Ξ²Μ‚ = βˆ’16,019; OR β‰ˆ 0"),
    paste0("G = ", round(G_stat,3), ", df=5, p < 0,001 β†’ Model signifikan"),
    round(1 - (fd_fit$deviance / fd_fit$null.deviance), 4),
    round(auc_train, 3),
    round(auc_test, 3),
    round(threshold_opt, 3),
    paste0(round(met_opt$accuracy * 100, 1), "%"),
    paste0(round(met_opt$sensitivity * 100, 1), "%"),
    paste0(round(met_opt$specificity * 100, 1), "%"),
    paste0(round(met_opt$balanced_accuracy * 100, 1), "%")
  )
)

knitr::kable(ringkasan_final,
             caption = "Ringkasan komprehensif hasil analisis",
             col.names = c("Aspek", "Hasil")) |>
  kableExtra::kable_styling(bootstrap_options = c("striped","hover")) |>
  kableExtra::row_spec(c(3,7,8), bold = TRUE, background = "#f0f9f8")
Ringkasan komprehensif hasil analisis
Aspek Hasil
Metode Regresi Logistik Biner (MLE)
Data 120 obs., 20 perusahaan transportasi BEI 2019–2024 &#124; Training 95 / Testing 25
Variabel signifikan (Ξ±=0,05) ROA (X₃) β€” p-value = 0,000289; Ξ²Μ‚ = βˆ’16,019; OR β‰ˆ 0
Uji simultan (LRT) G = 38.317, df=5, p < 0,001 β†’ Model signifikan
McFadden RΒ² 0.3644
AUC Training 0.912
AUC Testing 0.939
Threshold optimal (Youden) 0.266
Akurasi (thr. optimal) 88%
Sensitivity (thr. optimal) 66.7%
Specificity (thr. optimal) 94.7%
Balanced Accuracy (thr. optimal) 80.7%

8.2 Kesimpulan

1
Uji Simultan (LRT): Secara bersama-sama, kelima variabel prediktor (current ratio, debt ratio, ROA, sales growth, dan firm size) berpengaruh signifikan terhadap probabilitas financial distress (\(G = 38.317\), p < 0,001).
2
Uji Parsial (Wald): Dari kelima prediktor, hanya ROA (X₃) yang signifikan secara statistik (p = 0,000289). Koefisien negatif ROA mengonfirmasi bahwa perusahaan dengan profitabilitas lebih tinggi memiliki risiko financial distress lebih rendah β€” konsisten dengan teori dan penelitian terdahulu (Muflihah, 2017; Platt & Platt, 2002).
3
Kemampuan Prediksi: Model menunjukkan kemampuan diskriminasi sangat baik dengan AUC testing = 0.939 (> 0,9). Tidak ada indikasi overfitting karena AUC testing tidak lebih rendah dari AUC training.
4
Threshold Optimal: Threshold Youden (0.266) meningkatkan sensitivity dari 50,0% menjadi 66,7% tanpa mengorbankan specificity (tetap 94,7%), dengan balanced accuracy mencapai 80,7%. Threshold ini lebih direkomendasikan untuk sistem peringatan dini.

8.3 Implikasi Praktis

πŸ‘₯
Investor & Kreditur

ROA adalah indikator utama yang perlu dipantau. Perusahaan dengan ROA negatif atau mendekati nol perlu mendapat perhatian lebih sebelum pemberian kredit atau keputusan investasi.

🏒
Manajemen Perusahaan

Peningkatan profitabilitas (diukur lewat ROA) adalah prioritas utama untuk menjauh dari risiko financial distress. Efisiensi penggunaan aset perlu terus ditingkatkan.

πŸ›οΈ
Regulator & OJK

Model ini dapat diintegrasikan sebagai komponen early warning system untuk sektor transportasi, memungkinkan intervensi dini sebelum perusahaan jatuh ke kondisi bangkrut.

8.4 Keterbatasan

  1. Sampel terbatas (120 observasi, 20 perusahaan, 1 sektor) β€” generalisasi ke sektor lain perlu kehati-hatian dan validasi tambahan.
  2. Ketidakseimbangan kelas (75,8% non-distress) β€” penelitian lanjutan dapat mengeksplorasi teknik oversampling (SMOTE) atau cost-sensitive learning untuk meningkatkan sensitivity.
  3. Variabel terbatas pada rasio keuangan internal β€” faktor makroekonomi (inflasi, suku bunga, nilai tukar) dan tata kelola perusahaan (corporate governance) belum dipertimbangkan.

Daftar Pustaka

  1. Platt, H.D.; Platt, M.B. (2002). Predicting corporate financial distress: Reflections on choice-based sample bias. Journal of Economics and Finance, 26, 184–199.
  2. Muflihah, I. (2017). Analisis financial distress perusahaan manufaktur di Indonesia dengan regresi logistik. Majalah Ekonomi, 22, 254–269.
  3. Beaver, W.H. (1966). Financial ratios as predictors of failure. Journal of Accounting Research, 4, 71–111.
  4. Altman, E.I. (1968). Financial ratios, discriminant analysis and the prediction of corporate bankruptcy. Journal of Finance, 23, 589–609.
  5. Hosmer, D.W.; Lemeshow, S. (2000). Applied Logistic Regression (2nd ed.). John Wiley & Sons.
  6. Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition Letters, 27, 861–874.
  7. Youden, W.J. (1950). Index for rating diagnostic tests. Cancer, 3, 32–35.
  8. James, G.; Witten, D.; Hastie, T.; Tibshirani, R. (2013). An Introduction to Statistical Learning. Springer.