Pendahuluan

Ada sebuah perusahaan e-commerce yang punya dua sumber data soal pelanggannya: data pelanggan (identitas, usia, kota, pendapatan, status keaktifan) dan data transaksi (jumlah transaksi dan total pembelian). Masalahnya, dua data ini belum bisa langsung dipakai untuk analisis. Begitu dibuka, terlihat banyak hal janggal, seperti ada nilai yang hilang, penulisan kategori yang beda-beda padahal maksudnya sama, baris yang ternyata double, angka yang bernilai ekstrem, sampai nama kolom identifier yang tidak sama antara dua sumbernya.

Dokumen ini adalah catatan proses menyelesaikan kasus tersebut dari awal sampai akhir: mulai dari mendiagnosis apa saja yang salah dengan datanya, membersihkannya, menangani nilai hilang, menangani outlier, mentransformasi atribut numerik, sampai akhirnya menggabungkan dua sumber data itu jadi satu dataset yang siap dipakai untuk analisis lanjutan. Yang ditekankan di sini bukan cuma “apa yang dilakukan”, tapi juga “kenapa dilakukan seperti itu”. Setiap kali ada pilihan (misalnya pakai median dibanding mean, atau menghapus dibanding mengimputasi), alasannya dijelaskan, karena di dunia nyata data preprocessing memang bukan soal ikut resep, melainkan soal mengambil keputusan yang bisa dipertanggungjawabkan.

Membangun Dataset

Dataset di bawah ini sengaja dibuat “kotor” seperti kondisi data mentah pada umumnya, supaya seluruh proses preprocessing bisa didemonstrasikan secara nyata.

pelanggan_raw <- data.frame(
  customer_id = c("C001", "C002", "C003", "C004", "C005", "C006",
                  "C007", "C008", "C009", "C010", "C010", "C011"),
  nama = c("Ani", "Budi", "Citra", "Dodi", "Eka", "Fani",
           "Gilang", "Hana", "Indra", "Joko", "Joko", "Kiki"),
  usia = c(21, 25, 23, 150, 27, NA, 31, 29, 22, 35, 35, 28),
  pendapatan = c(4500000, NA, 5200000, 4800000, 4900000, 5100000,
                 500000000, 4700000, 4600000, 5300000, 5300000, NA),
  kota = c("Pekanbaru", " PKU", "PEKANBARU", "Dumai", "pekanbaru",
           "DUMAI", "Pekanbaru ", "Siak", "PKU", "Dumai", "Dumai", NA),
  status = c("Aktif", "aktif", "ACTIVE", "A", "Tidak Aktif", "nonaktif",
             "Aktif", "AKTIF", "A", "Tidak aktif", "Tidak aktif", "Aktif"),
  stringsAsFactors = FALSE
)

transaksi_raw <- data.frame(
  cust_id = c("C001", "C002", "C003", "C004", "C005", "C006",
              "C007", "C008", "C009", "C010", "C012"),
  jumlah_transaksi = c(5, 3, 7, 2, 6, 4, 20, 5, 3, 8, 1),
  total_purchase = c(1500000, 900000, 2700000, 600000, 2100000, 1300000,
                     25000000, 1700000, 800000, 3200000, 250000),
  stringsAsFactors = FALSE
)

pelanggan_raw
##    customer_id   nama usia pendapatan       kota      status
## 1         C001    Ani   21    4.5e+06  Pekanbaru       Aktif
## 2         C002   Budi   25         NA        PKU       aktif
## 3         C003  Citra   23    5.2e+06  PEKANBARU      ACTIVE
## 4         C004   Dodi  150    4.8e+06      Dumai           A
## 5         C005    Eka   27    4.9e+06  pekanbaru Tidak Aktif
## 6         C006   Fani   NA    5.1e+06      DUMAI    nonaktif
## 7         C007 Gilang   31    5.0e+08 Pekanbaru        Aktif
## 8         C008   Hana   29    4.7e+06       Siak       AKTIF
## 9         C009  Indra   22    4.6e+06        PKU           A
## 10        C010   Joko   35    5.3e+06      Dumai Tidak aktif
## 11        C010   Joko   35    5.3e+06      Dumai Tidak aktif
## 12        C011   Kiki   28         NA       <NA>       Aktif
transaksi_raw
##    cust_id jumlah_transaksi total_purchase
## 1     C001                5        1.5e+06
## 2     C002                3        9.0e+05
## 3     C003                7        2.7e+06
## 4     C004                2        6.0e+05
## 5     C005                6        2.1e+06
## 6     C006                4        1.3e+06
## 7     C007               20        2.5e+07
## 8     C008                5        1.7e+06
## 9     C009                3        8.0e+05
## 10    C010                8        3.2e+06
## 11    C012                1        2.5e+05

Sekilas sudah terlihat beberapa kejanggalan: baris C010 muncul dua kali, usia 150 tahun pada C004 jelas mustahil, dan pendapatan Rp500.000.000 pada C007 melompat jauh dari pelanggan lain. Semua kejanggalan ini akan ditelusuri satu per satu di bagian berikutnya, bukan langsung dihapus begitu saja.

Bagian I: Eksplorasi dan Diagnosis Kualitas Data

Memahami struktur data

Sebelum mengubah apa pun, langkah pertama yang perlu dilakukan adalah mengenali dulu bentuk dan tipe data yang sedang dihadapi.

dim(pelanggan_raw)
## [1] 12  6
names(pelanggan_raw)
## [1] "customer_id" "nama"        "usia"        "pendapatan"  "kota"       
## [6] "status"
str(pelanggan_raw)
## 'data.frame':    12 obs. of  6 variables:
##  $ customer_id: chr  "C001" "C002" "C003" "C004" ...
##  $ nama       : chr  "Ani" "Budi" "Citra" "Dodi" ...
##  $ usia       : num  21 25 23 150 27 NA 31 29 22 35 ...
##  $ pendapatan : num  4.5e+06 NA 5.2e+06 4.8e+06 4.9e+06 5.1e+06 5.0e+08 4.7e+06 4.6e+06 5.3e+06 ...
##  $ kota       : chr  "Pekanbaru" " PKU" "PEKANBARU" "Dumai" ...
##  $ status     : chr  "Aktif" "aktif" "ACTIVE" "A" ...
head(pelanggan_raw)
##   customer_id  nama usia pendapatan      kota      status
## 1        C001   Ani   21    4500000 Pekanbaru       Aktif
## 2        C002  Budi   25         NA       PKU       aktif
## 3        C003 Citra   23    5200000 PEKANBARU      ACTIVE
## 4        C004  Dodi  150    4800000     Dumai           A
## 5        C005   Eka   27    4900000 pekanbaru Tidak Aktif
## 6        C006  Fani   NA    5100000     DUMAI    nonaktif
summary(pelanggan_raw)
##  customer_id            nama                usia          pendapatan       
##  Length:12          Length:12          Min.   : 21.00   Min.   :  4500000  
##  Class :character   Class :character   1st Qu.: 24.00   1st Qu.:  4725000  
##  Mode  :character   Mode  :character   Median : 28.00   Median :  5000000  
##                                        Mean   : 38.73   Mean   : 54440000  
##                                        3rd Qu.: 33.00   3rd Qu.:  5275000  
##                                        Max.   :150.00   Max.   :500000000  
##                                        NA's   :1        NA's   :2          
##      kota              status         
##  Length:12          Length:12         
##  Class :character   Class :character  
##  Mode  :character   Mode  :character  
##                                       
##                                       
##                                       
## 

Ringkasan statistik dari summary() sudah kasih petunjuk awal yang cukup jelas: usia maksimum tercatat 150, pendapatan maksimum tercatat Rp500.000.000, padahal median keduanya jauh lebih rendah. Ini pertanda ada nilai ekstrem yang perlu diperiksa lebih dalam, bukan sesuatu yang bisa langsung disimpulkan sebagai outlier tanpa verifikasi.

Mengukur kualitas awal

Pemeriksaan kualitas data dilakukan dari beberapa sisi sekaligus: seberapa banyak nilai hilang, apakah ada duplikasi, seberapa konsisten penulisan kategori, dan bagaimana rentang nilai numeriknya.

# Jumlah dan persentase missing value per atribut
colSums(is.na(pelanggan_raw))
## customer_id        nama        usia  pendapatan        kota      status 
##           0           0           1           2           1           0
round(colMeans(is.na(pelanggan_raw)) * 100, 2)
## customer_id        nama        usia  pendapatan        kota      status 
##        0.00        0.00        8.33       16.67        8.33        0.00
# Duplikasi baris dan duplikasi customer_id
sum(duplicated(pelanggan_raw))
## [1] 1
sum(duplicated(pelanggan_raw$customer_id))
## [1] 1
# Variasi penulisan kategori
sort(unique(pelanggan_raw$kota))
## [1] " PKU"       "Dumai"      "DUMAI"      "pekanbaru"  "Pekanbaru" 
## [6] "PEKANBARU"  "Pekanbaru " "PKU"        "Siak"
sort(unique(pelanggan_raw$status))
## [1] "A"           "ACTIVE"      "aktif"       "Aktif"       "AKTIF"      
## [6] "nonaktif"    "Tidak aktif" "Tidak Aktif"
# Rentang atribut numerik
range(pelanggan_raw$usia, na.rm = TRUE)
## [1]  21 150
range(pelanggan_raw$pendapatan, na.rm = TRUE)
## [1] 4.5e+06 5.0e+08

Dari sini terlihat empat masalah utama pada data pelanggan:

  1. Ada nilai hilang pada atribut usia, pendapatan, dan kota.
  2. Ada satu baris yang duplikat persis, yaitu customer_id C010.
  3. Atribut kota dan status ditulis dengan variasi kapitalisasi, spasi, dan singkatan yang berbeda-beda padahal maknanya sama.
  4. Rentang usia (21 sampai 150) dan pendapatan (4,5 juta sampai 500 juta) mengandung nilai yang tidak wajar.

Membuat fungsi ringkasan kualitas

Karena kondisi kualitas data ini perlu dicek berulang kali, baik di awal maupun nanti di akhir proses, ada baiknya dibuat satu fungsi bantu supaya pengecekannya konsisten dan tidak perlu menulis ulang kode yang sama.

audit_data <- function(data) {
  data.frame(
    atribut = names(data),
    tipe = sapply(data, function(x) class(x)[1]),
    jumlah_missing = sapply(data, function(x) sum(is.na(x))),
    persen_missing = round(sapply(data, function(x) mean(is.na(x)) * 100), 2),
    jumlah_unik = sapply(data, function(x) length(unique(x))),
    row.names = NULL
  )
}

audit_awal <- audit_data(pelanggan_raw)
audit_awal
##       atribut      tipe jumlah_missing persen_missing jumlah_unik
## 1 customer_id character              0           0.00          11
## 2        nama character              0           0.00          11
## 3        usia   numeric              1           8.33          11
## 4  pendapatan   numeric              2          16.67          10
## 5        kota character              1           8.33          10
## 6      status character              0           0.00           8

Tabel audit ini nantinya jadi pembanding: kondisi data di titik ini akan disandingkan dengan kondisi data setelah seluruh proses preprocessing selesai.

Bagian II: Implementasi Data Cleaning

Membuat salinan kerja

Data mentah (pelanggan_raw) sengaja dibiarkan apa adanya sebagai arsip. Semua proses pembersihan dilakukan pada salinannya, supaya nanti masih bisa dibandingkan kondisi sebelum dan sesudahnya.

pelanggan <- pelanggan_raw

Membersihkan spasi dan kapitalisasi

Tulisan seperti " PKU", "Pekanbaru ", dan "PEKANBARU" sebenarnya merujuk ke hal yang sama, hanya beda cara penulisan. Sebelum kategori-kategori ini diseragamkan, spasi berlebih dan perbedaan huruf besar/kecil dirapikan dulu, supaya proses pencocokan berikutnya tidak meleset gara-gara hal sepele seperti spasi tersembunyi.

pelanggan$kota <- trimws(pelanggan$kota)
pelanggan$status <- trimws(pelanggan$status)

pelanggan$kota <- tolower(pelanggan$kota)
pelanggan$status <- tolower(pelanggan$status)

sort(unique(pelanggan$kota))
## [1] "dumai"     "pekanbaru" "pku"       "siak"
sort(unique(pelanggan$status))
## [1] "a"           "active"      "aktif"       "nonaktif"    "tidak aktif"

Menyeragamkan kategori

Setelah format penulisannya rapi, langkah berikutnya adalah memetakan tiap varian ke satu label baku. Pemetaannya didasarkan pada pemahaman terhadap arti tiap singkatan atau varian, misalnya “PKU” memang merujuk ke Pekanbaru dan “A” memang merujuk ke Aktif, bukan tebak-tebakan asal cocok.

pelanggan$kota[pelanggan$kota %in% c("pku", "pekanbaru")] <- "Pekanbaru"
pelanggan$kota[pelanggan$kota == "dumai"] <- "Dumai"
pelanggan$kota[pelanggan$kota == "siak"] <- "Siak"

pelanggan$status[pelanggan$status %in% c("aktif", "active", "a")] <- "Aktif"
pelanggan$status[pelanggan$status %in% c("tidak aktif", "nonaktif")] <- "Tidak Aktif"

sort(unique(pelanggan$kota))
## [1] "Dumai"     "Pekanbaru" "Siak"
sort(unique(pelanggan$status))
## [1] "Aktif"       "Tidak Aktif"

Setelah tahap ini, atribut kota cuma tersisa 3 kategori baku dan status cuma 2, turun jauh dari 10 dan 8 varian penulisan yang tadinya berserakan.

Mendeteksi dan menghapus duplikasi

Sebelum baris duplikat dihapus, cek apakah baris tersebut memang benar-benar identik di semua atributnya, bukan cuma kebetulan customer_id-nya sama padahal isinya beda.

pelanggan[duplicated(pelanggan$customer_id) |
          duplicated(pelanggan$customer_id, fromLast = TRUE), ]
##    customer_id nama usia pendapatan  kota      status
## 10        C010 Joko   35    5300000 Dumai Tidak Aktif
## 11        C010 Joko   35    5300000 Dumai Tidak Aktif

Ternyata kedua baris C010 memang identik persis di seluruh kolomnya, jadi aman untuk menyimpan kemunculan pertama saja dan membuang duplikatnya.

pelanggan <- pelanggan[!duplicated(pelanggan$customer_id), ]
rownames(pelanggan) <- NULL

dim(pelanggan)
## [1] 11  6

Memeriksa aturan domain

Selain duplikasi, nilai yang secara logika bisnis tidak masuk akal juga perlu diperiksa. Untuk kasus ini, ditetapkan aturan: usia pelanggan yang wajar ada di rentang 15 sampai 100 tahun, dan pendapatan tidak boleh bernilai negatif.

pelanggan[pelanggan$usia < 15 | pelanggan$usia > 100, ]
##    customer_id nama usia pendapatan  kota status
## 4         C004 Dodi  150    4800000 Dumai  Aktif
## NA        <NA> <NA>   NA         NA  <NA>   <NA>
pelanggan[pelanggan$pendapatan < 0, ]
##      customer_id nama usia pendapatan kota status
## NA          <NA> <NA>   NA         NA <NA>   <NA>
## NA.1        <NA> <NA>   NA         NA <NA>   <NA>

Ketemu satu pelanggan (C004) dengan usia tercatat 150 tahun, kemungkinan besar ini salah ketik dari 50. Setelah dicek ulang ke sumber data aslinya, nilai ini dikoreksi jadi 50, bukan dihapus, karena baris tersebut masih menyimpan informasi yang valid di kolom lainnya.

pelanggan$usia[pelanggan$customer_id == "C004"] <- 50

Sementara itu, pendapatan Rp500.000.000 pada C007 tidak melanggar aturan domain (tidak negatif), jadi belum dikoreksi di tahap ini. Nilai ini akan dievaluasi lagi sebagai kandidat outlier di Bagian IV.

Membuat log perubahan

Setiap perubahan yang sifatnya mengoreksi nilai perlu dicatat, supaya proses ini bisa ditelusuri ulang kapan saja dan tidak cuma mengandalkan ingatan soal “tadi kenapa angka ini diubah”.

log_perubahan <- data.frame(
  tahap = c("Standardisasi", "Standardisasi", "Deduplikasi", "Koreksi domain"),
  atribut = c("kota", "status", "customer_id", "usia"),
  tindakan = c(
    "PKU dan variasi kapital menjadi Pekanbaru",
    "ACTIVE/A menjadi Aktif; nonaktif menjadi Tidak Aktif",
    "Menghapus kemunculan kedua C010",
    "Usia C004 dikoreksi dari 150 menjadi 50 berdasarkan sumber asli"
  ),
  stringsAsFactors = FALSE
)

log_perubahan
##            tahap     atribut
## 1  Standardisasi        kota
## 2  Standardisasi      status
## 3    Deduplikasi customer_id
## 4 Koreksi domain        usia
##                                                          tindakan
## 1                       PKU dan variasi kapital menjadi Pekanbaru
## 2            ACTIVE/A menjadi Aktif; nonaktif menjadi Tidak Aktif
## 3                                 Menghapus kemunculan kedua C010
## 4 Usia C004 dikoreksi dari 150 menjadi 50 berdasarkan sumber asli

Bagian III: Penanganan Missing Values

Mengidentifikasi lokasi nilai hilang

colSums(is.na(pelanggan))
## customer_id        nama        usia  pendapatan        kota      status 
##           0           0           1           2           1           0
pelanggan[!complete.cases(pelanggan), ]
##    customer_id nama usia pendapatan      kota      status
## 2         C002 Budi   25         NA Pekanbaru       Aktif
## 6         C006 Fani   NA    5100000     Dumai Tidak Aktif
## 11        C011 Kiki   28         NA      <NA>       Aktif

Ada tiga baris yang punya setidaknya satu nilai hilang: pendapatan pada C002, usia pada C006, serta pendapatan dan kota sekaligus pada C011.

Strategi 1: menghapus baris

Cara paling gampang tentu saja langsung buang semua baris yang datanya tidak lengkap.

pelanggan_complete <- pelanggan[complete.cases(pelanggan), ]
nrow(pelanggan)
## [1] 11
nrow(pelanggan_complete)
## [1] 8
round((1 - nrow(pelanggan_complete) / nrow(pelanggan)) * 100, 2)
## [1] 27.27

Tapi ternyata cara ini membuang lebih dari seperempat data (sekitar 27%), padahal cuma gara-gara satu atau dua nilai hilang per baris. Untuk dataset sekecil ini, kehilangan sebanyak itu jelas terlalu mahal. Karena itu, strategi menghapus baris tidak dipakai sebagai solusi utama. Imputasi jadi pilihan yang lebih masuk akal, supaya informasi lain yang masih valid di baris-baris tersebut tidak ikut terbuang.

Strategi 2: imputasi mean dan median

mean_pendapatan <- mean(pelanggan$pendapatan, na.rm = TRUE)
median_pendapatan <- median(pelanggan$pendapatan, na.rm = TRUE)

mean_pendapatan
## [1] 59900000
median_pendapatan
## [1] 4900000

Rata-rata pendapatan (sekitar Rp59,9 juta) ternyata jauh lebih tinggi dari mediannya (Rp4,9 juta). Selisih sebesar ini disebabkan oleh nilai ekstrem milik C007. Karena mean gampang tertarik oleh outlier, median dipilih sebagai nilai imputasi untuk atribut pendapatan maupun usia, supaya hasil imputasinya tidak ikut terdistorsi oleh nilai ekstrem tadi.

pelanggan$pendapatan_imputasi <- pelanggan$pendapatan
pelanggan$pendapatan_imputasi[is.na(pelanggan$pendapatan_imputasi)] <-
  median_pendapatan

median_usia <- median(pelanggan$usia, na.rm = TRUE)
pelanggan$usia_imputasi <- pelanggan$usia
pelanggan$usia_imputasi[is.na(pelanggan$usia_imputasi)] <- median_usia

pelanggan[, c("customer_id", "usia", "usia_imputasi",
              "pendapatan", "pendapatan_imputasi")]
##    customer_id usia usia_imputasi pendapatan pendapatan_imputasi
## 1         C001   21          21.0    4.5e+06             4.5e+06
## 2         C002   25          25.0         NA             4.9e+06
## 3         C003   23          23.0    5.2e+06             5.2e+06
## 4         C004   50          50.0    4.8e+06             4.8e+06
## 5         C005   27          27.0    4.9e+06             4.9e+06
## 6         C006   NA          27.5    5.1e+06             5.1e+06
## 7         C007   31          31.0    5.0e+08             5.0e+08
## 8         C008   29          29.0    4.7e+06             4.7e+06
## 9         C009   22          22.0    4.6e+06             4.6e+06
## 10        C010   35          35.0    5.3e+06             5.3e+06
## 11        C011   28          28.0         NA             4.9e+06

Strategi 3: imputasi nilai kategorik

Untuk atribut kategorik seperti kota, mengisinya dengan modus bisa menyesatkan karena seolah-olah nilai itu memang diketahui pasti. Lebih jujur kalau nilai yang hilang diberi label eksplisit yang menandakan bahwa datanya memang tidak diketahui.

pelanggan$kota_imputasi <- pelanggan$kota
pelanggan$kota_imputasi[is.na(pelanggan$kota_imputasi)] <- "Tidak diketahui"

table(pelanggan$kota_imputasi, useNA = "ifany")
## 
##           Dumai       Pekanbaru            Siak Tidak diketahui 
##               3               6               1               1

Menambahkan indikator missing

Imputasi memang menutup nilai yang hilang, tapi di sisi lain juga menghapus jejak bahwa nilai itu sebelumnya tidak ada. Supaya jejaknya tetap terlihat, apalagi karena ada kemungkinan nilai yang hilang itu sendiri punya arti, ditambahkan satu kolom indikator biner untuk menandainya.

pelanggan$pendapatan_missing <- as.integer(is.na(pelanggan$pendapatan))
table(pelanggan$pendapatan_missing)
## 
## 0 1 
## 9 2

Membandingkan distribusi sebelum dan sesudah imputasi

par(mfrow = c(1, 2))
hist(pelanggan$pendapatan,
     main = "Sebelum Imputasi",
     xlab = "Pendapatan", col = "skyblue", breaks = 8)
hist(pelanggan$pendapatan_imputasi,
     main = "Sesudah Imputasi Median",
     xlab = "Pendapatan", col = "lightgreen", breaks = 8)

par(mfrow = c(1, 1))

Bentuk distribusinya secara umum masih terjaga setelah diimputasi, dengan nilai tambahan yang mengumpul di sekitar median. Ini memang sesuai perkiraan, karena begitu cara kerja imputasi median.

Bagian IV: Penanganan Outlier

Visualisasi dengan boxplot

boxplot(pelanggan$pendapatan_imputasi,
        horizontal = TRUE,
        col = "lightblue",
        main = "Boxplot Pendapatan",
        xlab = "Pendapatan")

Dari boxplot-nya kelihatan jelas ada satu titik yang jauh sendirian dari sebaran nilai lainnya. Ini kandidat outlier yang masih perlu diverifikasi lebih lanjut secara angka, bukan cuma dari kesan visual.

Menghitung batas IQR

q1 <- quantile(pelanggan$pendapatan_imputasi, 0.25)
q3 <- quantile(pelanggan$pendapatan_imputasi, 0.75)
iqr <- IQR(pelanggan$pendapatan_imputasi)

batas_bawah <- q1 - 1.5 * iqr
batas_atas <- q3 + 1.5 * iqr

c(Q1 = q1, Q3 = q3, IQR = iqr,
  batas_bawah = batas_bawah,
  batas_atas = batas_atas)
##          Q1.25%          Q3.75%             IQR batas_bawah.25%  batas_atas.75% 
##         4750000         5150000          400000         4150000         5750000

Menandai kandidat outlier

pelanggan$outlier_pendapatan <-
  pelanggan$pendapatan_imputasi < batas_bawah |
  pelanggan$pendapatan_imputasi > batas_atas

pelanggan[pelanggan$outlier_pendapatan,
          c("customer_id", "nama", "pendapatan_imputasi")]
##   customer_id   nama pendapatan_imputasi
## 7        C007 Gilang               5e+08

Kriteria IQR mengonfirmasi dugaan tadi: pendapatan C007 (Rp500.000.000) memang berada jauh di luar batas atas (Rp5.750.000). Jadi bukan cuma kesan visual dari boxplot, tapi sudah diverifikasi dengan angka.

Mengevaluasi tindakan

Baris ini tidak dihapus, karena tidak ada tanda-tanda kesalahan input seperti kasus usia 150 tahun sebelumnya. Bisa saja nilai ini memang benar, ada satu pelanggan dengan pendapatan yang jauh lebih tinggi dari yang lain. Menghapusnya berisiko membuang informasi yang sebenarnya valid. Sebagai jalan tengah, nilai ini ditangani dengan winsorizing: dibatasi (di-cap) pada batas atas IQR, supaya pengaruhnya terhadap analisis statistik berkurang tanpa harus kehilangan baris data itu sepenuhnya.

pelanggan$pendapatan_winsor <- pmin(
  pmax(pelanggan$pendapatan_imputasi, batas_bawah),
  batas_atas
)

pelanggan[pelanggan$outlier_pendapatan,
          c("customer_id", "pendapatan_imputasi", "pendapatan_winsor")]
##   customer_id pendapatan_imputasi pendapatan_winsor
## 7        C007               5e+08           5750000

Bagian V: Transformasi Data

Setelah nilai hilang dan outlier sudah ditangani, atribut numeriknya ditransformasi ke skala yang sebanding. Ini penting kalau nanti datanya mau dipakai untuk metode analisis yang sensitif terhadap skala, misalnya perhitungan jarak atau model berbasis gradien.

Normalisasi min-maks

minmax <- function(x) {
  if (all(is.na(x))) return(rep(NA_real_, length(x)))
  rentang <- max(x, na.rm = TRUE) - min(x, na.rm = TRUE)
  if (rentang == 0) return(rep(0, length(x)))
  (x - min(x, na.rm = TRUE)) / rentang
}

pelanggan$usia_minmax <- minmax(pelanggan$usia_imputasi)
pelanggan$pendapatan_minmax <- minmax(pelanggan$pendapatan_imputasi)

pelanggan[, c("customer_id", "usia", "usia_minmax",
              "pendapatan_imputasi", "pendapatan_minmax")]
##    customer_id usia usia_minmax pendapatan_imputasi pendapatan_minmax
## 1         C001   21  0.00000000             4.5e+06      0.0000000000
## 2         C002   25  0.13793103             4.9e+06      0.0008072654
## 3         C003   23  0.06896552             5.2e+06      0.0014127144
## 4         C004   50  1.00000000             4.8e+06      0.0006054490
## 5         C005   27  0.20689655             4.9e+06      0.0008072654
## 6         C006   NA  0.22413793             5.1e+06      0.0012108981
## 7         C007   31  0.34482759             5.0e+08      1.0000000000
## 8         C008   29  0.27586207             4.7e+06      0.0004036327
## 9         C009   22  0.03448276             4.6e+06      0.0002018163
## 10        C010   35  0.48275862             5.3e+06      0.0016145308
## 11        C011   28  0.24137931             4.9e+06      0.0008072654

Normalisasi z-score

pelanggan$usia_z <- as.numeric(scale(pelanggan$usia_imputasi))
pelanggan$pendapatan_z <- as.numeric(scale(pelanggan$pendapatan_imputasi))

round(pelanggan[, c("usia_z", "pendapatan_z")], 3)
##    usia_z pendapatan_z
## 1  -0.984       -0.304
## 2  -0.489       -0.301
## 3  -0.737       -0.299
## 4   2.604       -0.302
## 5  -0.242       -0.301
## 6  -0.180       -0.300
## 7   0.253        3.015
## 8   0.006       -0.303
## 9  -0.860       -0.303
## 10  0.748       -0.299
## 11 -0.118       -0.301

Decimal scaling

decimal_scale <- function(x) {
  maks <- max(abs(x), na.rm = TRUE)
  if (maks == 0) return(x)
  j <- ceiling(log10(maks + 1))
  x / (10 ^ j)
}

pelanggan$pendapatan_decimal <- decimal_scale(pelanggan$pendapatan_imputasi)
range(pelanggan$pendapatan_decimal, na.rm = TRUE)
## [1] 0.0045 0.5000

Membandingkan metode transformasi

transformasi <- pelanggan[, c(
  "customer_id", "pendapatan_imputasi", "pendapatan_minmax",
  "pendapatan_z", "pendapatan_decimal"
)]

transformasi
##    customer_id pendapatan_imputasi pendapatan_minmax pendapatan_z
## 1         C001             4.5e+06      0.0000000000   -0.3041235
## 2         C002             4.9e+06      0.0008072654   -0.3014440
## 3         C003             5.2e+06      0.0014127144   -0.2994343
## 4         C004             4.8e+06      0.0006054490   -0.3021138
## 5         C005             4.9e+06      0.0008072654   -0.3014440
## 6         C006             5.1e+06      0.0012108981   -0.3001042
## 7         C007             5.0e+08      1.0000000000    3.0151095
## 8         C008             4.7e+06      0.0004036327   -0.3027837
## 9         C009             4.6e+06      0.0002018163   -0.3034536
## 10        C010             5.3e+06      0.0016145308   -0.2987645
## 11        C011             4.9e+06      0.0008072654   -0.3014440
##    pendapatan_decimal
## 1              0.0045
## 2              0.0049
## 3              0.0052
## 4              0.0048
## 5              0.0049
## 6              0.0051
## 7              0.5000
## 8              0.0047
## 9              0.0046
## 10             0.0053
## 11             0.0049

Ketiga metode ini menghasilkan karakter skala yang beda-beda. Min-maks memampatkan semua nilai ke rentang 0 sampai 1, tapi sangat terpengaruh oleh nilai ekstrem C007, terlihat dari nilai-nilai lain yang jadi terdesak semua ke dekat angka 0. Z-score menunjukkan pola serupa, dengan C007 bernilai standar deviasi jauh di atas titik tengah (sekitar 3,0). Decimal scaling juga menunjukkan pola sebanding karena pembaginya ikut ditentukan oleh nilai maksimum. Dari sini bisa disimpulkan bahwa penanganan outlier memang perlu dilakukan sebelum normalisasi, bukan sesudahnya, karena nilai ekstrem yang belum ditangani akan mendominasi hasil transformasi apa pun metode yang dipakai.

Dampak outlier terhadap normalisasi

pelanggan$pendapatan_winsor_minmax <- minmax(pelanggan$pendapatan_winsor)

plot(pelanggan$pendapatan_minmax,
     pelanggan$pendapatan_winsor_minmax,
     pch = 19, col = "navy",
     xlab = "Min-maks Data Asli",
     ylab = "Min-maks Data Winsorized",
     main = "Dampak Penanganan Outlier terhadap Normalisasi")
abline(0, 1, col = "red", lty = 2)

Titik-titik yang menyimpang dari garis diagonal menggambarkan seberapa besar hasil normalisasi berubah setelah outlier-nya ditangani lebih dulu, jadi ini semacam konfirmasi visual atas kesimpulan di bagian sebelumnya.

Bagian VI: Integrasi Data

Memeriksa kunci pada kedua sumber

Sebelum digabungkan, kedua sumber data ini dicek dulu supaya tidak ada kejutan saat proses penggabungan berlangsung, misalnya ada customer_id yang cuma muncul di salah satu sisi saja.

sum(duplicated(pelanggan$customer_id))
## [1] 0
sum(duplicated(transaksi_raw$cust_id))
## [1] 0
setdiff(pelanggan$customer_id, transaksi_raw$cust_id)
## [1] "C011"
setdiff(transaksi_raw$cust_id, pelanggan$customer_id)
## [1] "C012"

Ternyata C011 ada di data pelanggan tapi tidak punya transaksi sama sekali, sementara C012 ada di data transaksi tapi tidak terdaftar sebagai pelanggan. Dua kondisi ini perlu ditangani dengan sadar, bukan diabaikan begitu saja.

Menyelaraskan nama identifier

Kedua sumber data ini pakai nama kolom identifier yang berbeda (customer_id di satu sisi, cust_id di sisi lain), jadi perlu diselaraskan dulu supaya bisa dijadikan kunci penggabungan.

transaksi <- transaksi_raw
names(transaksi)[names(transaksi) == "cust_id"] <- "customer_id"

Melakukan left join dengan merge()

Left join dipilih (bukan inner join) supaya semua pelanggan tetap dipertahankan, walaupun belum tentu punya transaksi, termasuk C011. Sebaliknya, C012 yang tidak terdaftar sebagai pelanggan otomatis tidak ikut masuk ke hasil akhirnya, karena data transaksi tanpa identitas pelanggan yang jelas memang tidak bisa dianalisis lebih jauh.

data_terintegrasi <- merge(
  pelanggan,
  transaksi,
  by = "customer_id",
  all.x = TRUE
)

data_terintegrasi[, c(
  "customer_id", "nama", "jumlah_transaksi", "total_purchase"
)]
##    customer_id   nama jumlah_transaksi total_purchase
## 1         C001    Ani                5        1.5e+06
## 2         C002   Budi                3        9.0e+05
## 3         C003  Citra                7        2.7e+06
## 4         C004   Dodi                2        6.0e+05
## 5         C005    Eka                6        2.1e+06
## 6         C006   Fani                4        1.3e+06
## 7         C007 Gilang               20        2.5e+07
## 8         C008   Hana                5        1.7e+06
## 9         C009  Indra                3        8.0e+05
## 10        C010   Joko                8        3.2e+06
## 11        C011   Kiki               NA             NA

Memvalidasi hasil integrasi

Validasi perlu dilakukan supaya proses penggabungan ini tidak diam-diam menimbulkan efek samping yang tidak diinginkan, misalnya baris yang jadi dobel atau jumlah baris yang berubah tanpa alasan jelas.

c(sebelum = nrow(pelanggan), sesudah = nrow(data_terintegrasi))
## sebelum sesudah 
##      11      11
sum(duplicated(data_terintegrasi$customer_id))
## [1] 0
colSums(is.na(data_terintegrasi[, c("jumlah_transaksi", "total_purchase")]))
## jumlah_transaksi   total_purchase 
##                1                1
data_terintegrasi[is.na(data_terintegrasi$jumlah_transaksi),
                  c("customer_id", "nama")]
##    customer_id nama
## 11        C011 Kiki

Jumlah barisnya tetap 11, sesuai jumlah pelanggan setelah deduplikasi. customer_id juga tetap unik, dan satu-satunya nilai hilang baru yang muncul memang cuma pada C011. Ini sesuai perkiraan, karena pelanggan tersebut memang belum pernah bertransaksi.

Mengisi nol atau mempertahankan NA?

Nilai hilang pada jumlah_transaksi dan total_purchase di sini punya makna yang beda dari nilai hilang di Bagian III. Bukan karena datanya tidak tercatat, tapi karena pelanggan tersebut memang belum pernah bertransaksi sama sekali. Setelah kondisi ini dipastikan, nilai NA-nya diisi dengan 0, bukan diimputasi pakai mean atau median seperti sebelumnya, karena 0 memang nilai yang benar secara makna untuk “belum pernah bertransaksi”, bukan sekadar perkiraan statistik.

data_terintegrasi$jumlah_transaksi_final <- data_terintegrasi$jumlah_transaksi
data_terintegrasi$total_purchase_final <- data_terintegrasi$total_purchase

data_terintegrasi$jumlah_transaksi_final[
  is.na(data_terintegrasi$jumlah_transaksi_final)
] <- 0

data_terintegrasi$total_purchase_final[
  is.na(data_terintegrasi$total_purchase_final)
] <- 0

Dataset Akhir dan Evaluasi

Memilih atribut akhir

Dari semua kolom yang terbentuk sepanjang proses (termasuk kolom perantara seperti pendapatan_z atau pendapatan_decimal yang tadinya dibuat cuma untuk membandingkan metode), dipilih kombinasi atribut yang memang relevan untuk dataset analisis akhir.

data_final <- data_terintegrasi[, c(
  "customer_id", "nama", "usia_imputasi", "kota_imputasi", "status",
  "pendapatan_imputasi", "pendapatan_missing", "outlier_pendapatan",
  "usia_minmax", "pendapatan_minmax", "jumlah_transaksi_final",
  "total_purchase_final"
)]

names(data_final)[names(data_final) == "kota_imputasi"] <- "kota"
names(data_final)[names(data_final) == "usia_imputasi"] <- "usia"

data_final
##    customer_id   nama usia            kota      status pendapatan_imputasi
## 1         C001    Ani 21.0       Pekanbaru       Aktif             4.5e+06
## 2         C002   Budi 25.0       Pekanbaru       Aktif             4.9e+06
## 3         C003  Citra 23.0       Pekanbaru       Aktif             5.2e+06
## 4         C004   Dodi 50.0           Dumai       Aktif             4.8e+06
## 5         C005    Eka 27.0       Pekanbaru Tidak Aktif             4.9e+06
## 6         C006   Fani 27.5           Dumai Tidak Aktif             5.1e+06
## 7         C007 Gilang 31.0       Pekanbaru       Aktif             5.0e+08
## 8         C008   Hana 29.0            Siak       Aktif             4.7e+06
## 9         C009  Indra 22.0       Pekanbaru       Aktif             4.6e+06
## 10        C010   Joko 35.0           Dumai Tidak Aktif             5.3e+06
## 11        C011   Kiki 28.0 Tidak diketahui       Aktif             4.9e+06
##    pendapatan_missing outlier_pendapatan usia_minmax pendapatan_minmax
## 1                   0              FALSE  0.00000000      0.0000000000
## 2                   1              FALSE  0.13793103      0.0008072654
## 3                   0              FALSE  0.06896552      0.0014127144
## 4                   0              FALSE  1.00000000      0.0006054490
## 5                   0              FALSE  0.20689655      0.0008072654
## 6                   0              FALSE  0.22413793      0.0012108981
## 7                   0               TRUE  0.34482759      1.0000000000
## 8                   0              FALSE  0.27586207      0.0004036327
## 9                   0              FALSE  0.03448276      0.0002018163
## 10                  0              FALSE  0.48275862      0.0016145308
## 11                  1              FALSE  0.24137931      0.0008072654
##    jumlah_transaksi_final total_purchase_final
## 1                       5              1.5e+06
## 2                       3              9.0e+05
## 3                       7              2.7e+06
## 4                       2              6.0e+05
## 5                       6              2.1e+06
## 6                       4              1.3e+06
## 7                      20              2.5e+07
## 8                       5              1.7e+06
## 9                       3              8.0e+05
## 10                      8              3.2e+06
## 11                      0              0.0e+00

Audit akhir

audit_akhir <- audit_data(data_final)
audit_akhir
##                   atribut      tipe jumlah_missing persen_missing jumlah_unik
## 1             customer_id character              0              0          11
## 2                    nama character              0              0          11
## 3                    usia   numeric              0              0          11
## 4                    kota character              0              0           4
## 5                  status character              0              0           2
## 6     pendapatan_imputasi   numeric              0              0           9
## 7      pendapatan_missing   integer              0              0           2
## 8      outlier_pendapatan   logical              0              0           2
## 9             usia_minmax   numeric              0              0          11
## 10      pendapatan_minmax   numeric              0              0           9
## 11 jumlah_transaksi_final   numeric              0              0           9
## 12   total_purchase_final   numeric              0              0          11
sum(duplicated(data_final$customer_id))
## [1] 0
colSums(is.na(data_final))
##            customer_id                   nama                   usia 
##                      0                      0                      0 
##                   kota                 status    pendapatan_imputasi 
##                      0                      0                      0 
##     pendapatan_missing     outlier_pendapatan            usia_minmax 
##                      0                      0                      0 
##      pendapatan_minmax jumlah_transaksi_final   total_purchase_final 
##                      0                      0                      0

Dataset akhir ini sudah tidak lagi punya nilai hilang atau duplikasi customer_id, jauh berbeda dari kondisi data mentah di awal tadi.

Membandingkan kondisi sebelum dan sesudah

perbandingan <- data.frame(
  indikator = c(
    "Jumlah baris",
    "Duplikasi customer_id",
    "Total missing value",
    "Kategori kota unik",
    "Kategori status unik"
  ),
  sebelum = c(
    nrow(pelanggan_raw),
    sum(duplicated(pelanggan_raw$customer_id)),
    sum(is.na(pelanggan_raw)),
    length(unique(pelanggan_raw$kota)),
    length(unique(pelanggan_raw$status))
  ),
  sesudah = c(
    nrow(data_final),
    sum(duplicated(data_final$customer_id)),
    sum(is.na(data_final)),
    length(unique(data_final$kota)),
    length(unique(data_final$status))
  )
)

perbandingan
##               indikator sebelum sesudah
## 1          Jumlah baris      12      11
## 2 Duplikasi customer_id       1       0
## 3   Total missing value       4       0
## 4    Kategori kota unik      10       4
## 5  Kategori status unik       8       2

Tabel ini merangkum dampak keseluruhan dari proses preprocessing tadi: jumlah baris berkurang satu gara-gara deduplikasi, duplikasi dan nilai hilang sudah tuntas ditangani, dan jumlah kategori pada kota maupun status menyusut jauh karena sudah diseragamkan.

Menyimpan hasil

write.csv(data_final,
          "data_pelanggan_setelah_preprocessing.csv",
          row.names = FALSE)

write.csv(log_perubahan,
          "log_perubahan_preprocessing.csv",
          row.names = FALSE)

Ringkasan

Kasus ini menunjukkan kalau data preprocessing itu bukan sekadar menjalankan rangkaian perintah teknis, tapi lebih ke serangkaian keputusan yang masing-masing punya alasan dan bisa dipertanggungjawabkan. Beberapa keputusan penting yang diambil sepanjang proses ini:

  • Baris duplikat baru dihapus setelah dipastikan memang identik di semua atributnya, bukan cuma karena customer_id-nya mirip.
  • Usia 150 tahun dikoreksi setelah dicek ulang ke sumber aslinya, bukan langsung dihapus atau ditebak begitu saja.
  • Median dipilih sebagai nilai imputasi (bukan mean), karena distribusi pendapatannya condong akibat nilai ekstrem.
  • Outlier pendapatan tidak dihapus, tapi ditangani dengan winsorizing supaya informasinya tidak hilang sepenuhnya.
  • Penanganan outlier dilakukan lebih dulu sebelum normalisasi, karena urutan ini terbukti berpengaruh besar terhadap hasil transformasinya.
  • Left join dipilih di tahap integrasi supaya semua pelanggan tetap terwakili, dengan nilai hilang hasil integrasi baru diisi 0 setelah maknanya (belum pernah bertransaksi) dipastikan, bukan diperlakukan sama dengan nilai hilang di tahap sebelumnya.

Dataset akhir (data_final) beserta jejak seluruh perubahannya (log_perubahan) sekarang sudah siap dipakai untuk tahap analisis lanjutan, seperti segmentasi pelanggan atau pemodelan prediktif.