Metrik di luar akurasi

Confusion matrix dan akurasi per kelas sebagai pelengkap akurasi.

Daftar isi

Akurasi global merangkum seluruh prediksi menjadi satu angka, dan satu angka itu dapat menyembunyikan kegagalan besar pada kelas tertentu. Topik ini melengkapi akurasi dengan dua alat pemeriksaan, yaitu confusion matrix sebagai tabel persilangan antara label sebenarnya dan prediksi model, serta akurasi per kelas yang dihitung darinya. Seluruh perhitungan menggunakan operasi torch murni pada data sintetis yang sengaja dibuat tidak seimbang, sehingga kelemahan akurasi terlihat langsung pada keluaran.

Mengapa akurasi global menyesatkan#

Ketika jumlah sampel antara kelas tidak seimbang, akurasi global didominasi oleh kelas dengan anggota terbanyak. Hitungannya sederhana, misalnya 1000 sampel dengan 900 sampel kelas 0: model yang selalu menebak kelas 0 tanpa memandang masukan memperoleh akurasi 90 persen, meskipun kemampuannya nol untuk dua kelas lainnya.

Ketidakseimbangan semacam ini bukan keadaan langka. Deteksi transaksi tidak wajar, pemeriksaan cacat produk, dan pencarian dokumen langka semuanya memuat kelas minoritas yang justru paling penting dikenali. Pada data tersebut, model yang mengejar akurasi global cenderung mengabaikan kelas minoritas, karena menebak kelas mayoritas saja sudah memberi angka yang tampak tinggi. Karena itu, keputusan penerimaan model pada data tidak seimbang tidak boleh bertumpu pada satu angka akurasi global.

Confusion matrix dengan operasi torch murni#

Confusion matrix berukuran (K, K) untuk K kelas, dengan baris menyatakan label sebenarnya dan kolom menyatakan prediksi model. Sel diagonal berarti prediksi benar, sedangkan sel di luar diagonal berarti prediksi keliru beserta arah kekeliruannya, yaitu kelas asli yang diprediksi sebagai kelas lain. Matriks disimpan sebagai tensor int64 berisi penghitungan, dan setiap pasangan sampel menambah satu pada sel dengan indeks [label, pred].

Orientasi baris dan kolom tersebut digambarkan sebagai berikut:

Orientasi confusion matrixpred 0pred 1pred 2label 0label 1label 2prediksi modellabel sebenarnyadiagonal: prediksi benarluar diagonal: prediksi kelirucm[label, pred] diisi satuper pasangan sampel,dengan tipe int64
Orientasi confusion matrix: baris untuk label sebenarnya, kolom untuk prediksi

Skrip berikut melatih model mini pada data 1000 sampel dengan komposisi 900, 90, dan 10, lalu mencetak confusion matrix dalam bentuk tabel beserta akurasi global dan akurasi per kelas:

python confusion-matrix.py
"""Confusion matrix dan akurasi per kelas dengan operasi torch murni."""

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(0)

# data sintetis tiga kelas yang sengaja tidak seimbang
jumlah = {0: 900, 1: 90, 2: 10}
pusat = torch.tensor([[0.0, 0.0], [2.8, 2.8], [-2.8, 2.8]])
fitur = []
label = []
for k, n in jumlah.items():
    fitur.append(pusat[k] + 0.8 * torch.randn(n, 2))
    label.append(torch.full((n,), k))
fitur = torch.cat(fitur)
label = torch.cat(label)

dataset = TensorDataset(fitur, label)
loader_latih = DataLoader(dataset, batch_size=32, shuffle=True,
                          generator=torch.Generator().manual_seed(0))
loader_eval = DataLoader(dataset, batch_size=100, shuffle=False)

model = nn.Sequential(nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 3))
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
criterion = nn.CrossEntropyLoss()

# pelatihan singkat, 5 epoch
model.train()
for epoch in range(5):
    for xb, yb in loader_latih:
        optimizer.zero_grad()
        loss = criterion(model(xb), yb)
        loss.backward()
        optimizer.step()

# kumpulkan prediksi dalam mode evaluasi
model.eval()
pred_semua = []
label_semua = []
with torch.no_grad():
    for xb, yb in loader_eval:
        pred_semua.append(model(xb).argmax(dim=1))
        label_semua.append(yb)
pred = torch.cat(pred_semua)
label = torch.cat(label_semua)

# confusion matrix: baris = label sebenarnya, kolom = prediksi
K = 3
cm = torch.zeros(K, K, dtype=torch.int64)
for l, p in zip(label.tolist(), pred.tolist()):
    cm[l, p] += 1

akurasi_global = cm.diagonal().sum().item() / cm.sum().item()
akurasi_kelas = cm.diagonal().float() / cm.sum(dim=1).float()

# cetak confusion matrix sebagai tabel rapi
print("confusion matrix (baris = label sebenarnya, kolom = prediksi)")
print("         " + "  ".join(f"{'pred ' + str(j):>7}" for j in range(K)))
for i in range(K):
    sel = "  ".join(f"{cm[i, j].item():7d}" for j in range(K))
    print(f"label {i}  {sel}")
print()
print(f"akurasi global  : {akurasi_global:.4f}")
for i in range(K):
    print(f"akurasi kelas {i} : {akurasi_kelas[i].item():.4f}")
text Keluaran
confusion matrix (baris = label sebenarnya, kolom = prediksi)
          pred 0   pred 1   pred 2
label 0      896        4        0
label 1        2       88        0
label 2       10        0        0

akurasi global  : 0.9840
akurasi kelas 0 : 0.9956
akurasi kelas 1 : 0.9778
akurasi kelas 2 : 0.0000

Keluarannya memperlihatkan masalah yang tidak tampak pada akurasi global 0.9840. Baris label 2 menunjukkan seluruh 10 sampel kelas 2 diprediksi sebagai kelas 0, sehingga akurasi kelas 2 bernilai 0.0000, sedangkan kolom pred 2 kosong sama sekali, yang berarti model tidak pernah menebak kelas 2. Angka global tetap tinggi karena 90 persen data adalah kelas 0, persis seperti model pembanding yang selalu menebak kelas mayoritas.

Akurasi per kelas dihitung dari matriks dengan rumus berikut, dengan CC menyatakan confusion matrix dan kk menyatakan indeks kelas:

ak=Ckk∑j=1KCkj a_k = \frac{C_{kk}}{\sum_{j=1}^{K} C_{kj}}

Pembilang rumus tersebut adalah sel diagonal kelas kk, sedangkan penyebutnya jumlah seluruh baris kk, yaitu banyaknya sampel yang benar-benar berasal dari kelas tersebut.

Jebakan indeks duplikat pada pengisian matriks#

Baris cm[label, pred] += 1 tampak seperti cara ringkas untuk mengisi matriks sekaligus, tetapi baris itu diam-diam menghasilkan hitungan yang keliru ketika ada pasangan indeks yang berulang. Python mengeksekusi pernyataan tersebut sebagai operasi baca, tambah satu, lalu tulis kembali, sehingga semua kemunculan pasangan yang sama membaca nilai lama yang identik sebelum ada penulisan, dan sel yang bersangkutan hanya bertambah satu. Skrip kecil berikut membandingkan kedua cara pada lima sampel yang tiga di antaranya berpasangan sama:

python indeks-duplikat.py
"""Perbandingan cm[label, pred] += 1 dengan iterasi pasangan indeks."""

import torch

label = torch.tensor([0, 0, 0, 1, 2])
pred = torch.tensor([1, 1, 1, 1, 0])
K = 3

# cara yang tampak ringkas, tetapi salah untuk indeks berulang
cm_salah = torch.zeros(K, K, dtype=torch.int64)
cm_salah[label, pred] += 1

# cara yang benar: iterasi pasangan label dan prediksi
cm_benar = torch.zeros(K, K, dtype=torch.int64)
for l, p in zip(label.tolist(), pred.tolist()):
    cm_benar[l, p] += 1

print("label :", label.tolist())
print("pred  :", pred.tolist())
print("cm_salah:")
print(cm_salah)
print("cm_benar:")
print(cm_benar)
text Keluaran
label : [0, 0, 0, 1, 2]
pred  : [1, 1, 1, 1, 0]
cm_salah:
tensor([[0, 1, 0],
        [0, 1, 0],
        [1, 0, 0]])
cm_benar:
tensor([[0, 3, 0],
        [0, 1, 0],
        [1, 0, 0]])

Tiga sampel dengan pasangan (0, 1) seharusnya mengisi sel tersebut dengan angka tiga, tetapi cara berindeks hanya meninggalkan angka satu. Cara yang benar adalah mengiterasi pasangan label dan prediksi satu per satu, sebagaimana digunakan pada skrip utama. Alternatif lain yang tetap berbasis torch adalah torch.bincount(label * K + pred, minlength=K * K).reshape(K, K), yaitu menghitung kemunculan setiap pasangan pada satu vektor datar sebelum dibentuk ulang menjadi matriks.

Kesalahan umum#

Kesalahan pertama adalah menukar orientasi, misalnya menulis cm[pred, label]. Matriks yang terbalik tetap memiliki diagonal yang sama, sehingga akurasi global tidak mendeteksi kekeliruannya, sedangkan seluruh akurasi per kelas dan arah kekeliruan menjadi salah. Perbaikannya adalah menetapkan satu konvensi sejak awal, yaitu baris untuk label sebenarnya dan kolom untuk prediksi, lalu menggunakannya secara konsisten.

Kesalahan kedua adalah menyimpan matriks dengan tipe float. Penghitungan seharusnya bulat, dan tipe pecahan hanya memperbesar risiko ketidaktepatan serta memboroskan memori. Perbaikannya adalah membuat matriks dengan dtype=torch.int64, lalu memanggil .float() tepat sebelum pembagian akurasi per kelas.

Kesalahan ketiga adalah membagi tanpa memeriksa baris kosong. Kelas yang tidak muncul sama sekali pada data evaluasi menghasilkan penyebut nol, dan pembagian itu menghasilkan nan yang merusak tabel laporan. Perbaikannya adalah memeriksa jumlah baris terlebih dahulu, lalu melaporkan kelas tanpa sampel sebagai data yang belum terukur, bukan sebagai akurasi nol.

Ringkasan#

  • Confusion matrix berukuran (K, K) bertipe int64 menghitung pasangan label sebenarnya sebagai baris dan prediksi sebagai kolom.
  • Akurasi per kelas adalah sel diagonal dibagi jumlah barisnya, dan nilainya membongkar kegagalan yang disembunyikan akurasi global.
  • cm[label, pred] += 1 tidak aman untuk indeks berulang, sehingga pengisian menggunakan iterasi pasangan atau torch.bincount.
  • Akurasi per kelas identik dengan recall kelas yang bersangkutan.

Referensi#