Sentiment analysis adalah studi kasus text classification yang paling sering dipasang di dunia nyata: satu kalimat ulasan harus dipetakan ke sentimen positif, negatif, atau netral. Pipeline-nya persis chapter Text Classification ; yang berubah adalah karakter datanya. Ulasan produk relatif tertib, sedangkan unggahan media sosial singkat, penuh singkatan, dan sering menyampaikan sentimen lewat ironi. Chapter ini membangun model TF-IDF + LogisticRegression pada 240 ulasan mini tiga kelas, lalu memperlihatkan satu kegagalan nyata yang khas domain ini: kalimat negasi “tidak bagus” yang salah diprediksi sebagai positif.

Dua Kanal, Satu Masalah #

Sumber teks menentukan tingkat kesulitan. Tabel berikut merangkum dua kanal yang paling umum:

AspekUlasan produkMedia sosial
Panjang teks2-5 kalimat1 kalimat atau lebih pendek
Gaya bahasarelatif bakusingkatan, emotikon, huruf kapital berlebih
Sasaran sentimenproduk dan layanantopik apa pun, sering berganti di tengah teks
Jebakan khasnegasi “tidak bagus”sarkasme “wow, bagus sekali” bermakna negatif

Tiga tantangan muncul berulang di kedua kanal. Pertama, negasi: kata “bagus” di dalam “tidak bagus” tetap terbaca positif oleh model yang hanya melihat kamus kata. Kedua, sarkasme: kalimat “pesanan datang cepat, tinggal tiga minggu” mengejek lewat fakta yang tampak memuji. Ketiga, netral: sebagian besar teks nyata bukan pujian maupun keluhan — deskripsi faktual, pertanyaan, atau pengumuman. Model yang hanya dilatih dua kelas akan memaksakan label ke teks netral.

Demo: TF-IDF + LogisticRegression Tiga Kelas #

Berkas analisis_sentimen.py membangun 240 ulasan dari templat — termasuk kalimat negatif bernegasi seperti “produk ini tidak enak” dan kalimat positif bernegasi seperti “kemasan tidak rusak” agar kata “tidak” muncul di dua kelas sekaligus. Model A memakai fitur unigram, model B menambah bigram:

python
"""Analisis sentimen 3 kelas (positif/negatif/netral) dengan TF-IDF +
LogisticRegression scikit-learn pada dataset mini buatan 240 ulasan.

Bagian 1: model unigram -> akurasi + prediksi 5 kalimat baru
(muncul kegagalan nyata pada kalimat negasi "tidak bagus").
Bagian 2: bedah bobot kata (koefisien LogisticRegression per kelas).
Bagian 3: model unigram+bigram -> kalimat negasi teratasi.
"""

import random

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split

acak = random.Random(7)

# ---------- 1. Dataset mini buatan: 80 positif + 80 negatif + 80 netral ----------
K_POSITIF = ["bagus", "mantap", "enak", "awet"]
S_POSITIF = ["senang", "puas", "terkesan"]
K_NEGATIF = ["buruk", "parah", "menyebalkan", "mengecewakan"]
S_NEGATIF = ["kecewa", "kesal", "menyesal"]
K_NEGASI = ["enak", "awet"]
S_NEGASI = ["puas", "senang"]

TEMPLAT_POSITIF = [
    ("produk ini {kata} sekali", K_POSITIF),
    ("produk ini {kata} dan tahan lama", K_POSITIF),
    ("saya {kata} dengan produk ini", S_POSITIF),
    ("pengiriman cepat dan paket tiba utuh", None),
    ("kualitasnya {kata} untuk harga seperti ini", K_POSITIF),
    ("pelayanan tokonya ramah dan responsif", None),
    ("harga sebanding dengan kualitasnya", None),
    ("kemasan tidak rusak selama pengiriman", None),
    ("harga tidak mahal untuk kualitas seperti ini", None),
    ("pelayanan tidak mengecewakan sama sekali", None),
]
TEMPLAT_NEGATIF = [
    ("produk ini {kata} sekali", K_NEGATIF),
    ("saya {kata} dengan produk ini", S_NEGATIF),
    ("pengiriman lambat dan paket rusak", None),
    ("kualitasnya {kata} untuk harga seperti ini", K_NEGATIF),
    ("pelayanan tokonya lambat merespons", None),
    ("produk ini tidak {kata}", K_NEGASI),
    ("saya tidak {kata} dengan produk ini", S_NEGASI),
]
TEMPLAT_NETRAL = [
    ("produk datang sesuai deskripsi", None),
    ("saya memesan produk ini kemarin", None),
    ("kemasan standar dan aman", None),
    ("pengiriman mengikuti jadwal toko", None),
    ("ukuran dan warna sesuai foto", None),
    ("produk ini dijual dengan harga seratus ribu", None),
    ("saya belum mencoba produk ini", None),
    ("toko ini menjual tiga macam warna", None),
]


def buat_dataset(jumlah_per_kelas):
    """Siklus templat bergilir agar tiap pola terwakili merata."""
    data = []
    for templat, label in (
        (TEMPLAT_POSITIF, "positif"),
        (TEMPLAT_NEGATIF, "negatif"),
        (TEMPLAT_NETRAL, "netral"),
    ):
        for i in range(jumlah_per_kelas):
            pola, kosakata = templat[i % len(templat)]
            kata = acak.choice(kosakata) if kosakata else ""
            data.append((pola.format(kata=kata), label))
    acak.shuffle(data)
    return data


data = buat_dataset(80)
teks_semua = [t for t, _ in data]
label_semua = [l for _, l in data]
teks_latih, teks_uji, label_latih, label_uji = train_test_split(
    teks_semua, label_semua, test_size=0.25, random_state=7, stratify=label_semua
)
print(f"dataset: {len(data)} ulasan (80 positif | 80 negatif | 80 netral) "
      f"| latih {len(teks_latih)} | uji {len(teks_uji)}")

KALIMAT_BARU = [
    ("produk ini bagus dan tahan lama", "positif"),
    ("produk ini tidak bagus", "negatif"),
    ("saya kecewa dengan produk ini", "negatif"),
    ("paket datang sesuai jadwal toko", "netral"),
    ("saya tidak puas dengan kualitasnya", "negatif"),
]


def latih_dan_uji(ngram_range, tanda):
    vektor = TfidfVectorizer(lowercase=True, ngram_range=ngram_range)
    X_latih = vektor.fit_transform(teks_latih)
    X_uji = vektor.transform(teks_uji)
    model = LogisticRegression(max_iter=1000, random_state=7)
    model.fit(X_latih, label_latih)
    akurasi_uji = accuracy_score(label_uji, model.predict(X_uji))
    print(f"\n== model {tanda} | fitur {X_latih.shape[1]} | "
          f"akurasi uji {akurasi_uji:.3f} ==")
    return vektor, model


def prediksi_tabel(vektor, model, daftar_kalimat):
    print("prediksi 5 kalimat baru:")
    for teks, label_benar in daftar_kalimat:
        hasil = model.predict(vektor.transform([teks]))[0]
        status = "BENAR" if hasil == label_benar else "SALAH"
        print(f"  '{teks}'")
        print(f"    -> {hasil} (seharusnya {label_benar}) {status}")


# ---------- 2. Model A: unigram saja ----------
vektor_a, model_a = latih_dan_uji((1, 1), "A (unigram)")
prediksi_tabel(vektor_a, model_a, KALIMAT_BARU)

# ---------- 3. Bedah bobot kata model A ----------
nama_fitur = vektor_a.get_feature_names_out()
indeks_kelas = {nama: i for i, nama in enumerate(model_a.classes_)}
print("\ntop bobot kata model A (koefisien LogisticRegression):")
for kelas in ("positif", "negatif"):
    bobot = model_a.coef_[indeks_kelas[kelas]]
    urutan = bobot.argsort()[::-1][:5]
    pasangan = ", ".join(f"{nama_fitur[i]}={bobot[i]:+.2f}" for i in urutan)
    print(f"  kelas {kelas} (5 bobot terbesar): {pasangan}")

print("bobot kata ambigu pada model A:")
for kata in ("tidak", "bagus", "puas", "rusak"):
    if kata in nama_fitur:
        i = list(nama_fitur).index(kata)
        baris = ", ".join(
            f"{kelas}={model_a.coef_[indeks_kelas[kelas]][i]:+.2f}"
            for kelas in model_a.classes_
        )
        print(f"  '{kata}': {baris}")

# ---------- 4. Model B: unigram + bigram ----------
vektor_b, model_b = latih_dan_uji((1, 2), "B (unigram + bigram)")
prediksi_tabel(vektor_b, model_b, KALIMAT_BARU)

nama_fitur_b = vektor_b.get_feature_names_out()
bobot_b = model_b.coef_[indeks_kelas["negatif"]]
indeks_bigram = [i for i, nama in enumerate(nama_fitur_b) if " " in nama]
print("\ntop bobot bigram pada kelas negatif (model B):")
urutan_b = sorted(indeks_bigram, key=lambda i: bobot_b[i])[::-1][:4]
for i in urutan_b:
    print(f"  '{nama_fitur_b[i]}' bobot {bobot_b[i]:+.2f}")

print("bobot bigram yang memuat kata 'tidak' (model B):")
indeks_tidak = [i for i, nama in enumerate(nama_fitur_b)
                if "tidak" in nama.split()]
for i in sorted(indeks_tidak, key=lambda i: bobot_b[i], reverse=True):
    baris = ", ".join(
        f"{kelas}={model_b.coef_[indeks_kelas[kelas]][i]:+.2f}"
        for kelas in model_b.classes_
    )
    print(f"  '{nama_fitur_b[i]}': {baris}")

Skrip di atas menghasilkan keluaran berikut saat dijalankan di mesin uji (CPU, scikit-learn 1.5.2). Perhatikan baris bertanda SALAH pada model A lalu hilang pada model B:

Output yang Dihasilkan:

text
dataset: 240 ulasan (80 positif | 80 negatif | 80 netral) | latih 180 | uji 60

== model A (unigram) | fitur 66 | akurasi uji 0.933 ==
prediksi 5 kalimat baru:
  'produk ini bagus dan tahan lama'
    -> positif (seharusnya positif) BENAR
  'produk ini tidak bagus'
    -> positif (seharusnya negatif) SALAH
  'saya kecewa dengan produk ini'
    -> negatif (seharusnya negatif) BENAR
  'paket datang sesuai jadwal toko'
    -> netral (seharusnya netral) BENAR
  'saya tidak puas dengan kualitasnya'
    -> negatif (seharusnya negatif) BENAR

top bobot kata model A (koefisien LogisticRegression):
  kelas positif (5 bobot terbesar): bagus=+1.13, selama=+1.04, sekali=+0.96, sebanding=+0.83, terkesan=+0.82
  kelas negatif (5 bobot terbesar): lambat=+1.86, menyebalkan=+1.23, tidak=+1.18, mengecewakan=+0.97, ini=+0.94
bobot kata ambigu pada model A:
  'tidak': negatif=+1.18, netral=-1.15, positif=-0.03
  'bagus': negatif=-0.72, netral=-0.42, positif=+1.13
  'puas': negatif=+0.07, netral=-0.28, positif=+0.21
  'rusak': negatif=+0.63, netral=-0.71, positif=+0.08

== model B (unigram + bigram) | fitur 179 | akurasi uji 0.917 ==
prediksi 5 kalimat baru:
  'produk ini bagus dan tahan lama'
    -> positif (seharusnya positif) BENAR
  'produk ini tidak bagus'
    -> negatif (seharusnya negatif) BENAR
  'saya kecewa dengan produk ini'
    -> negatif (seharusnya negatif) BENAR
  'paket datang sesuai jadwal toko'
    -> netral (seharusnya netral) BENAR
  'saya tidak puas dengan kualitasnya'
    -> negatif (seharusnya negatif) BENAR

top bobot bigram pada kelas negatif (model B):
  'ini tidak' bobot +0.98
  'dengan produk' bobot +0.82
  'saya tidak' bobot +0.74
  'pengiriman lambat' bobot +0.73
bobot bigram yang memuat kata 'tidak' (model B):
  'ini tidak': negatif=+0.98, netral=-0.38, positif=-0.60
  'saya tidak': negatif=+0.74, netral=-0.26, positif=-0.49
  'tidak': negatif=+0.69, netral=-0.77, positif=+0.08
  'tidak awet': negatif=+0.67, netral=-0.26, positif=-0.41
  'tidak senang': negatif=+0.56, netral=-0.19, positif=-0.37
  'tidak enak': negatif=+0.45, netral=-0.18, positif=-0.27
  'tidak puas': negatif=+0.27, netral=-0.10, positif=-0.18
  'harga tidak': negatif=-0.24, netral=-0.15, positif=+0.40
  'tidak mahal': negatif=-0.24, netral=-0.15, positif=+0.40
  'pelayanan tidak': negatif=-0.27, netral=-0.14, positif=+0.41
  'tidak mengecewakan': negatif=-0.27, netral=-0.14, positif=+0.41
  'kemasan tidak': negatif=-0.28, netral=-0.24, positif=+0.52
  'tidak rusak': negatif=-0.28, netral=-0.24, positif=+0.52

Mari kita telaah kode di atas baris per baris:

  1. Baris 19-58 (templat tiga kelas): tiap kelas punya pola kalimat sendiri; templat negatif memuat dua pola negasi dan templat positif memuat tiga konteks “tidak” yang justru memuji — sebaran ini membuat kata “tidak” ambigu, persis seperti data nyata.
  2. Baris 60-74 (pengisian merata): siklus i % len(templat) menjaga tiap pola terwakili sama rata, bukan diacak penuh, sehingga tidak ada kata kunci yang kebetulan absen dari data latih.
  3. Baris 85-91 (kalimat uji): lima kalimat baru memuat dua kasus negasi — bahan uji kelemahan unigram.
  4. Baris 94-113 (latih dua model): fungsi yang sama dijalankan dua kali dengan ngram_range berbeda; TfidfVectorizer mengubah teks menjadi matriks fitur sparse, prediksi_tabel mencetak hasil tiap kalimat.
  5. Baris 119-137 (bedah bobot): koefisien LogisticRegression dibaca per kelas — kata dengan bobot terbesar adalah kata yang paling mendorong prediksi ke kelas tersebut.
  6. Baris 139-159 (model B + bigram): bigram yang memuat “tidak” dicetak dengan bobot ketiga kelas untuk memperlihatkan arah maknanya.

Membaca Kegagalan Negasi #

Model A mencapai akurasi uji 0,933, namun gagal pada “produk ini tidak bagus”. Bedah bobot menjelaskan alasannya: kata “bagus” membawa bobot +1,13 untuk kelas positif dan kata “tidak” +1,18 untuk kelas negatif. Karena “bagus” jarang muncul di korpus, nilai TF-IDF-nya besar, dan skor positif akhirnya menang atas kata “tidak” yang sering muncul. Model kata-per-kata memang tidak punya representasi untuk urutan: “tidak bagus” dan “bagus, tidak masalah” terhitung sama.

Sebaliknya, “saya tidak puas dengan kualitasnya” luput dari kegagalan bukan karena model memahami negasi: bobot “puas” (+0,21 positif) terlalu lemah untuk menyaingi “tidak” (+1,18 negatif), sehingga prediksi jatuh ke negatif secara kebetulan. Nasib tiap kalimat negasi ditentukan oleh pertarungan besaran bobot dua kata — bukan oleh pemahaman struktur kalimat. Pada korpus lain, arah pertarungan ini bisa berbalik.

Bigram: Makna yang Ikut Konteks #

Model B menambahkan pasangan kata berdampingan sebagai fitur. Keluaran memperlihatkan pola yang rapi: bigram “tidak awet”, “tidak enak”, dan “saya tidak” berbobot negatif, sedangkan “tidak rusak”, “tidak mahal”, dan “tidak mengecewakan” berbobot positif. Kata “tidak” tidak lagi membawa satu bobot tunggal; maknanya mengikuti kata di sebelah kanannya. Seluruh lima kalimat uji pun terklasifikasi benar.

Metrik akurasi yang dipakai kedua model dihitung dengan cara yang sama seperti chapter Metrik Klasifikasi , dan pola kesalahan per kelas dapat dibedah lewat Confusion Matrix . Sentimen adalah contoh teks dipetakan ke satu label untuk seluruh kalimat. Chapter berikutnya berpindah ke tugas yang jauh lebih halus: menandai bagian tertentu dari kalimat — nama orang, tempat, dan tanggal — satu per satu token.