Bab-bab sebelumnya di Section ini banyak bertumpu pada kolom numerik: nilai transaksi, jumlah terjual, peluang suatu kejadian. Padahal sebagian besar kolom dunia nyata adalah kategorikal — menu, cabang, tingkat kepuasan — dan pertanyaan penting sering justru menghubungkan dua kolom kategorikal: apakah kedua cabang Toko Kopi Kita melayani pelanggan dengan kualitas yang berbeda? Kita sudah mencicipi tabulasi silang saat menjelajahi data pada Section Eksplorasi Data; pada chapter ini kita memperlankannya sebagai alat analisis yang tuntas — dari frekuensi dan proporsi, tabel kontingensi beserta marginnya, uji chi-square untuk menilai apakah polanya bukan kebetulan, sampai menjaga urutan bermakna pada kategori ordinal.
Merangkum Satu Kolom Kategorikal #
Ringkasan satu kolom kategorikal bertumpu pada tiga angka: frekuensi (berapa kali tiap kategori muncul), proporsi (bagian tiap kategori terhadap total), dan modus (kategori terbanyak). Berbeda dengan kolom numerik, rata-rata tidak berlaku — tidak ada “menu rata-rata” yang bisa dihitung dari kopi susu dan teh tawar. Sebagai bahan, kita pakai survei 28 pelanggan Toko Kopi Kita yang mencatat menu favorit, cabang kunjungan, dan tingkat kepuasan:
import pandas as pd
survei = pd.DataFrame({
"menu_favorit": [
"kopi susu", "teh tawar", "kopi susu", "matcha latte", "kopi susu",
"teh tawar", "kopi susu", "matcha latte", "kopi susu", "teh tawar",
"kopi susu", "kopi susu", "teh tawar", "matcha latte", "kopi susu",
"teh tawar", "kopi susu", "matcha latte", "kopi susu", "teh tawar",
"kopi susu", "teh tawar", "kopi susu", "matcha latte", "kopi susu",
"teh tawar", "kopi susu", "matcha latte",
],
"cabang": [
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Surabaya", "Bandung",
"Surabaya", "Bandung", "Surabaya",
],
"kepuasan": [
"puas", "kurang puas", "sangat puas", "puas", "tidak puas",
"kurang puas", "sangat puas", "puas", "sangat puas", "kurang puas",
"puas", "puas", "kurang puas", "sangat puas", "kurang puas",
"puas", "sangat puas", "kurang puas", "puas", "tidak puas",
"sangat puas", "puas", "kurang puas", "kurang puas", "sangat puas",
"puas", "puas", "kurang puas",
],
})
frekuensi = survei["menu_favorit"].value_counts()
proporsi = survei["menu_favorit"].value_counts(normalize=True) * 100
print("Frekuensi:")
print(frekuensi)
print()
print("Proporsi (%):")
print(proporsi.round(1))
print()
print("Modus:", survei["menu_favorit"].mode().tolist())Output yang Dihasilkan:
Frekuensi:
menu_favorit
kopi susu 14
teh tawar 8
matcha latte 6
Name: count, dtype: int64
Proporsi (%):
menu_favorit
kopi susu 50.0
teh tawar 28.6
matcha latte 21.4
Name: proportion, dtype: float64
Modus: ['kopi susu']Mari kita telaah baris-baris kode di atas:
value_counts()menghitung frekuensi tiap kategori dan mengurutkannya dari yang terbanyak — siapa pemuncaknya langsung terlihat di puncak daftar.value_counts(normalize=True)mengubah hitungan menjadi proporsi; dikalikan 100 agar terbaca sebagai persen. Proporsi menjadi wajib begitu kita membandingkan kelompok yang ukurannya berbeda — 14 pesanan kopi susu dari 28 responden (50%) tidak setara dengan 14 pesanan dari 200 responden (7%).mode()mengembalikan kategori terbanyak dalam bentuk list, bukan satu nilai, karena dua kategori bisa seri di posisi puncak.
Menyilangkan Dua Kolom dalam Tabel Kontingensi #
Frekuensi satu kolom menjawab “yang paling populer apa?”. Tabel kontingensi (pd.crosstab) menjawab pertanyaan berlapis: bagaimana kepuasan tersebar pada tiap cabang? Kali ini kita bangun ulang dua kolom yang diperlukan agar blok kode tetap mandiri, lalu menyilangkannya dengan margin total:
import pandas as pd
survei = pd.DataFrame({
"kepuasan": [
"puas", "kurang puas", "sangat puas", "puas", "tidak puas",
"kurang puas", "sangat puas", "puas", "sangat puas", "kurang puas",
"puas", "puas", "kurang puas", "sangat puas", "kurang puas",
"puas", "sangat puas", "kurang puas", "puas", "tidak puas",
"sangat puas", "puas", "kurang puas", "kurang puas", "sangat puas",
"puas", "puas", "kurang puas",
],
"cabang": [
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Surabaya", "Bandung",
"Surabaya", "Bandung", "Surabaya",
],
})
tabel = pd.crosstab(survei["kepuasan"], survei["cabang"], margins=True, margins_name="Total")
print("Tabel kontingensi dengan margin:")
print(tabel)
print()
print("Komposisi tiap cabang (normalize='columns'):")
print(pd.crosstab(survei["kepuasan"], survei["cabang"], normalize="columns").round(3))Output yang Dihasilkan:
Tabel kontingensi dengan margin:
cabang Bandung Surabaya Total
kepuasan
kurang puas 2 7 9
puas 7 3 10
sangat puas 7 0 7
tidak puas 0 2 2
Total 16 12 28
Komposisi tiap cabang (normalize='columns'):
cabang Bandung Surabaya
kepuasan
kurang puas 0.125 0.583
puas 0.438 0.250
sangat puas 0.438 0.000
tidak puas 0.000 0.167Cara membaca tabel tersebut dari tiga arah:
- Per baris — baris
sangat puasberisi 7 Bandung dan 0 Surabaya: seluruh pelanggan yang sangat puas datang dari Bandung. - Per kolom — kolom Surabaya berisi 12 responden; dari jumlah itu 7 orang (58,3%) hanya sampai level kurang puas. Kolom Bandung menceritakan kebalikannya: 14 dari 16 responden (87,5%) berada di level puas atau sangat puas.
- Margin total — baris dan kolom
Totalmenjadi penyebut alami saat kita ingin membandingkan kedua cabang secara adil; hitungan mentah 7 lawan 3 menyesatkan bila lupa bahwa jumlah respondennya 16 lawan 12.
Inilah sebabnya normalize="columns" kita pakai di sini: pertanyaan kita adalah “komposisi kepuasan di tiap cabang seperti apa?”, sehingga tiap kolom dinormalkan menjadi berjumlah 1. Membalik arah ke normalize="index" menjawab pertanyaan berbeda — “pelanggan yang kurang puas itu tersebar di cabang mana?” — dan kesalahan arah membuat kesimpulan terbalik, seperti sudah kita latih pada Section Eksplorasi Data.
Menguji Pola dengan Chi-Square #
Tabel di atas memperlihatkan pola yang timpang, tetapi dengan 28 responden pola serupa bisa muncul dari ulir acak semata. Uji chi-square kebebasan (chi-square test of independence) menghitung seberapa jauh tabel teramati menjauh dari tabel yang diharapkan bila kedua kolom benar-benar tidak berhubungan. Hipotesis nolnya: cabang dan kepuasan saling lepas — proporsi kepuasan kedua cabang sama. Statistiknya menjumlahkan selisih teramati terhadap ekspektasi:
chi-square = Σ (teramati - ekspektasi)² / ekspektasi
ekspektasi = (total baris × total kolom) / total keseluruhanSemakin besar selisihnya, semakin kecil p-value — peluang melihat penyimpangan sebesar itu (atau lebih) bila hipotesis nol benar. Kita hitung dengan scipy.stats.chi2_contingency:
import pandas as pd
from scipy.stats import chi2_contingency
survei = pd.DataFrame({
"kepuasan": [
"puas", "kurang puas", "sangat puas", "puas", "tidak puas",
"kurang puas", "sangat puas", "puas", "sangat puas", "kurang puas",
"puas", "puas", "kurang puas", "sangat puas", "kurang puas",
"puas", "sangat puas", "kurang puas", "puas", "tidak puas",
"sangat puas", "puas", "kurang puas", "kurang puas", "sangat puas",
"puas", "puas", "kurang puas",
],
"cabang": [
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Surabaya", "Bandung", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Bandung", "Surabaya",
"Bandung", "Bandung", "Surabaya", "Surabaya", "Bandung",
"Surabaya", "Bandung", "Surabaya",
],
})
tabel_amati = pd.crosstab(survei["cabang"], survei["kepuasan"])
stat, p, dof, ekspektasi = chi2_contingency(tabel_amati)
print("Statistik chi-square : %.3f" % stat)
print("Derajat kebebasan :", dof)
print("p-value : %.4f" % p)
print()
print("Hitungan ekspektasi jika cabang dan kepuasan saling lepas:")
print(pd.DataFrame(
ekspektasi.round(1),
index=tabel_amati.index,
columns=tabel_amati.columns,
))Output yang Dihasilkan:
Statistik chi-square : 13.073
Derajat kebebasan : 3
p-value : 0.0045
Hitungan ekspektasi jika cabang dan kepuasan saling lepas:
kepuasan kurang puas puas sangat puas tidak puas
cabang
Bandung 5.1 5.7 4.0 1.1
Surabaya 3.9 4.3 3.0 0.9Bedah kode dan pembacaan hasilnya:
chi2_contingency(tabel_amati)menerima tabel kontingensi — bukan data mentah — dan mengembalikan empat nilai sekaligus: statistik chi-square, p-value, derajat kebebasan, dan tabel ekspektasi.- Derajat kebebasan 3 berasal dari
(jumlah baris - 1) × (jumlah kolom - 1)=(2-1) × (4-1). Tabel 2×4 hanya punya 3 sel yang bebas bergerak setelah margin dikunci. - p-value 0,0045 berada di bawah ambang 0,05: bila kedua kolom benar-benar saling lepas, peluang melihat tabel setimpang ini hanya sekitar 0,45%. Kita menolak hipotesis nol — ada hubungan antara cabang dan kepuasan.
- Tabel ekspektasi menunjukkan dari mana penyimpangan datang: sel
sangat puas × Surabayamenuntut 3,0 responden tetapi teramati 0, sementarakurang puas × Surabayamenuntut 3,9 tetapi teramati 7. Chi-square tidak menyebut sel mana yang berbeda; kita yang membacanya dari selisih teramati lawan ekspektasi.
Ada dua hal lagi yang perlu kita jaga saat menafsirkan hasil chi-square. Pertama, hubungan bukan sebab-akibat: chi-square hanya mengatakan kedua kolom bergerak bersama, tanpa membedakan apakah cabangnya yang menentukan kualitas layanan atau faktor lain — lokasi yang lebih ramai, jam kunjungan, profil pelanggan — yang berbeda antar cabang. Kedua, pada survei kecil seperti ini satu responden yang berpindah kategori saja bisa menggeser hasil; laporan yang baik menyertakan jumlah responden dan tabel hitungannya, bukan hanya p-value.
Menjaga Urutan pada Kategori Ordinal #
Kategori nominal seperti menu_favorit tidak punya urutan bermakna, tetapi kepuasan punya: tidak puas lebih rendah daripada kurang puas, dan seterusnya. Kolom seperti ini disebut ordinal, dan urutannya wajib dijaga sebelum analisis — karena pengurutan bawaan pandas mengikuti abjad, bukan skala:
import pandas as pd
kepuasan = pd.Series([
"puas", "kurang puas", "sangat puas", "puas", "tidak puas",
"kurang puas", "sangat puas", "puas", "sangat puas", "kurang puas",
"puas", "puas", "kurang puas", "sangat puas", "kurang puas",
"puas", "sangat puas", "kurang puas", "puas", "tidak puas",
"sangat puas", "puas", "kurang puas", "kurang puas", "sangat puas",
"puas", "puas", "kurang puas",
])
print("Urutan abjad (bawaan sort_index):")
print(kepuasan.value_counts().sort_index())
print()
urutan_skala = ["tidak puas", "kurang puas", "puas", "sangat puas"]
kepuasan = pd.Series(
pd.Categorical(kepuasan, categories=urutan_skala, ordered=True)
)
hitungan = kepuasan.value_counts().sort_index()
print("Urutan skala (kategori ordinal):")
print(hitungan)
print()
kumulatif = hitungan.cumsum() / hitungan.sum() * 100
print("Proporsi kumulatif (%):")
print(kumulatif.round(1))Output yang Dihasilkan:
Urutan abjad (bawaan sort_index):
kurang puas 9
puas 10
sangat puas 7
tidak puas 2
Name: count, dtype: int64
Urutan skala (kategori ordinal):
tidak puas 2
kurang puas 9
puas 10
sangat puas 7
Name: count, dtype: int64
Proporsi kumulatif (%):
tidak puas 7.1
kurang puas 39.3
puas 75.0
sangat puas 100.0
Name: count, dtype: float64Perhatikan jebakan pada tabel pertama: tidak puas — level terendah — mendarat di posisi terakhir karena urutan abjad menempatkannya setelah sangat puas. Grafik batang yang dibangun dari tabel seperti itu akan menyusun sumbunya terbalik. Dua baris berikut memperbaikinya:
pd.Categorical(..., ordered=True)mengunci urutan kategori sesuai skala yang kita definisikan, sehinggasort_index(), grafik, dan pengelompokan mengikuti urutan bermakna, bukan urutan abjad.cumsum()menghitung frekuensi kumulatif mengikuti urutan skala. Pembacaannya: 39,3% responden berada di level kurang puas ke bawah, dan 75% berada di level puas ke bawah — artinya hanya 25% responden yang mencapai sangat puas. Proporsi kumulatif adalah cara paling ringkas meringkas skala ordinal.
| Aspek | Kategori nominal | Kategori ordinal |
|---|---|---|
| Contoh di survei kita | menu_favorit, cabang | kepuasan |
| Urutan bermakna | Tidak ada | Ada, harus dikunci lewat pd.Categorical |
| Ringkasan yang tepat | Frekuensi, proporsi, modus | Tambahan: proporsi kumulatif, median |
| Jebakan umum | Label tidak konsisten (kapital, spasi) | Urutan abjad mengaburkan urutan skala |
Rangkuman #
- Satu kolom kategorikal diringkas dengan frekuensi (
value_counts()), proporsi (normalize=True), dan modus (mode()); rata-rata tidak berlaku untuk label. pd.crosstabmenyilangkan dua kolom kategorikal; margin (margins=True) dannormalizemembaca tabel dari arah baris, kolom, maupun total — pilih arah sesuai pertanyaan.scipy.stats.chi2_contingencymenguji apakah pola tabel kontingensi cukup jauh dari ekspektasi “saling lepas”; p-value kecil berarti ada hubungan, tetapi bukan sebab-akibat, dan sel ekspektasi di bawah 5 membuat hasil perlu diperlakukan hati-hati.- Kategori ordinal perlu diurutkan eksplisit dengan
pd.Categorical(..., ordered=True)karena urutan bawaan mengikuti abjad; proporsi kumulatif adalah ringkasan alami skala ordinal.
Kita kini bisa membaca dan menguji pola kategorikal secara tuntas. Namun survei di atas menyisakan pertanyaan baru yang tipenya berbeda: nilai transaksi pelanggan Bandung memang terlihat lebih besar daripada Surabaya — selisih semacam itu nyata atau cuma fluktuasi sampel? Menguji perbedaan antar kelompok adalah tugas analisis komparatif: uji-t untuk dua kelompok dan ANOVA untuk lebih banyak, yang kita bahas di chapter berikutnya. Lanjut ke chapter Analisis Komparatif .