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:

python
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:

text
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:

  1. value_counts() menghitung frekuensi tiap kategori dan mengurutkannya dari yang terbanyak — siapa pemuncaknya langsung terlihat di puncak daftar.
  2. 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%).
  3. 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:

python
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:

text
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.167

Cara membaca tabel tersebut dari tiga arah:

  1. Per baris — baris sangat puas berisi 7 Bandung dan 0 Surabaya: seluruh pelanggan yang sangat puas datang dari Bandung.
  2. 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.
  3. Margin total — baris dan kolom Total menjadi 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:

text
chi-square = Σ (teramati - ekspektasi)² / ekspektasi
ekspektasi = (total baris × total kolom) / total keseluruhan

Semakin besar selisihnya, semakin kecil p-value — peluang melihat penyimpangan sebesar itu (atau lebih) bila hipotesis nol benar. Kita hitung dengan scipy.stats.chi2_contingency:

python
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:

text
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.9

Bedah kode dan pembacaan hasilnya:

  1. 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.
  2. 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.
  3. 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.
  4. Tabel ekspektasi menunjukkan dari mana penyimpangan datang: sel sangat puas × Surabaya menuntut 3,0 responden tetapi teramati 0, sementara kurang puas × Surabaya menuntut 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:

python
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:

text
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: float64

Perhatikan 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:

  1. pd.Categorical(..., ordered=True) mengunci urutan kategori sesuai skala yang kita definisikan, sehingga sort_index(), grafik, dan pengelompokan mengikuti urutan bermakna, bukan urutan abjad.
  2. 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.
AspekKategori nominalKategori ordinal
Contoh di survei kitamenu_favorit, cabangkepuasan
Urutan bermaknaTidak adaAda, harus dikunci lewat pd.Categorical
Ringkasan yang tepatFrekuensi, proporsi, modusTambahan: proporsi kumulatif, median
Jebakan umumLabel 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.crosstab menyilangkan dua kolom kategorikal; margin (margins=True) dan normalize membaca tabel dari arah baris, kolom, maupun total — pilih arah sesuai pertanyaan.
  • scipy.stats.chi2_contingency menguji 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 .

Referensi #

  1. SciPy — scipy.stats
  2. pandas — User Guide: Reshaping