Model NLP tidak membaca teks mentah; model membaca deret bilangan bulat. Pekerjaan pertama proyek ini adalah mengubah 300 ulasan produk berbahasa Indonesia menjadi pasangan tensor dan label yang siap dilatih. Kita mulai dari sumbernya: sebuah generator dataset yang deterministik, dilanjutkan modul preprocessing yang bertanggung jawab atas empat tahap klasik — pembersihan, tokenisasi, penyusunan vocabulary, dan pengubahan teks menjadi id.

Dataset Ulasan Fiktif #

Dataset ulasan.csv dibangkitkan dari kombinasi templat kalimat dan kosakata perasaan. Tiga kelas dibangun dengan sinyal yang berbeda:

KelasSumber templatContoh kata kunci
positifpujian produk, pengiriman, pelayananbagus, mantap, memuaskan, awet
netralpertanyaan dan pernyataan faktualtersedia, bertanya, resi, memesan
negatifkeluhan produk, pengiriman, kemasanburuk, rusak, lambat, kusut

Beberapa templat sengaja dipakai bersama dua kelas — misalnya pengiriman sangat {kata} — sehingga model tidak bisa mengandalkan frasa tetap dan harus benar-benar mempelajari makna kata. Tokoh Budi, Siti, Dewi, Rina, dan Agus muncul sebagai pembeli fiktif pada sebagian templat. Berikut generatornya:

python Generator dataset ulasan (data/generate_data.py)
"""Generator dataset ulasan produk fiktif untuk proyek analisis sentimen.

Membangkitkan 300 ulasan produk daring berbahasa Indonesia dalam tiga kelas
sentimen (positif, netral, negatif) dari kombinasi templat dan kosakata.
Seluruh tokoh (Budi, Siti, Dewi) dan isi ulasan adalah data fiktif.
Seed tetap memastikan data/ulasan.csv identik pada setiap eksekusi.

Pemakaian:
    python3 generate_data.py
"""

import csv
import random
from pathlib import Path

SEED = 42
JUMLAH_PER_KELAS = 100
BERKAS_OUTPUT = Path(__file__).resolve().parent / "ulasan.csv"

NAMA = ["Budi", "Siti", "Dewi", "Rina", "Agus"]

KATA_POSITIF = [
    "bagus", "mantap", "memuaskan", "nyaman", "enak",
    "awet", "cepat", "rapi", "hemat", "bersih",
]
KATA_NEGATIF = [
    "buruk", "mengecewakan", "rusak", "lambat", "parah",
    "kusut", "bocor", "mahal", "kotor", "pecah",
]

TEMPLAT_POSITIF = [
    "produk ini {kata} sekali",
    "kualitasnya {kata} dan tahan lama",
    "pengiriman sangat {kata}",
    "pelayanan tokonya {kata}",
    "{nama} senang karena barangnya {kata}",
    "belanja di toko ini {kata} sekali",
    "harga sebanding dengan kualitas yang {kata}",
    "kemasannya rapi dan isinya {kata}",
]

TEMPLAT_NETRAL = [
    "apakah produk ini tersedia warna lain",
    "berapa lama pengiriman ke luar kota",
    "barang sesuai deskripsi pada halaman produk",
    "{nama} bertanya tentang ukuran yang tersedia",
    "tokonya mengirim paket hari senin",
    "stok produk belum diketahui pasti",
    "nomor resi pengiriman belum aktif",
    "{nama} memesan dua unit untuk keperluan kantor",
]

TEMPLAT_NEGATIF = [
    "produk ini {kata} sekali",
    "kualitasnya {kata} dan cepat rusak",
    "pengiriman sangat {kata}",
    "pelayanan tokonya {kata}",
    "{nama} kecewa karena barangnya {kata}",
    "harga tidak sebanding dengan kualitas yang {kata}",
    "paket tiba dalam kondisi {kata}",
    "kemasannya {kata} dan tidak lengkap",
]


def bangkitkan_ulasan(acak):
    """Bangkitkan daftar (ulasan, label) seimbang untuk tiga kelas."""
    data = []
    for _ in range(JUMLAH_PER_KELAS):
        kata = acak.choice(KATA_POSITIF)
        nama = acak.choice(NAMA)
        templat = acak.choice(TEMPLAT_POSITIF)
        data.append((templat.format(kata=kata, nama=nama), "positif"))
    for _ in range(JUMLAH_PER_KELAS):
        nama = acak.choice(NAMA)
        templat = acak.choice(TEMPLAT_NETRAL)
        data.append((templat.format(nama=nama), "netral"))
    for _ in range(JUMLAH_PER_KELAS):
        kata = acak.choice(KATA_NEGATIF)
        nama = acak.choice(NAMA)
        templat = acak.choice(TEMPLAT_NEGATIF)
        data.append((templat.format(kata=kata, nama=nama), "negatif"))
    acak.shuffle(data)
    return data


def main():
    acak = random.Random(SEED)
    data = bangkitkan_ulasan(acak)

    with BERKAS_OUTPUT.open("w", newline="", encoding="utf-8") as f:
        penulis = csv.writer(f)
        penulis.writerow(["ulasan", "label"])
        penulis.writerows(data)

    jumlah = {}
    for _, label in data:
        jumlah[label] = jumlah.get(label, 0) + 1

    print(f"berkas output : {BERKAS_OUTPUT.name}")
    print(f"total ulasan  : {len(data)}")
    print("jumlah kelas  :", {k: jumlah[k] for k in ("positif", "netral", "negatif")})
    print("contoh baris  :")
    for teks, label in data[:3]:
        print(f"  {label:8s} | {teks}")


if __name__ == "__main__":
    main()

Output yang Dihasilkan:

text
berkas output : ulasan.csv
total ulasan  : 300
jumlah kelas  : {'positif': 100, 'netral': 100, 'negatif': 100}
contoh baris  :
  negatif  | kualitasnya parah dan cepat rusak
  negatif  | paket tiba dalam kondisi bocor
  negatif  | produk ini bocor sekali

Mari kita telaah kode di atas baris per baris:

  1. Baris 16-18 (seed dan jumlah): SEED = 42 mengunci seluruh pemilihan templat dan kata; JUMLAH_PER_KELAS = 100 menghasilkan 300 baris seimbang sehingga akurasi adalah metrik yang adil.
  2. Baris 65-83 (bangkitkan_ulasan): setiap kelas mengambil kata dari kosakatanya sendiri, memilih templat secara acak, lalu mengisi placeholder {kata} dan {nama}. Pemanggilan acak.shuffle di akhir memastikan tiga kelas tercampur sebelum ditulis ke CSV.
  3. Baris 86-100 (main): csv.writer menulis kepala ulasan,label diikuti 300 baris data; BERKAS_OUTPUT dihitung dari lokasi berkas sehingga skrip bisa dijalankan dari direktori mana pun.

Pembersihan, Tokenisasi, dan Vocabulary #

Modul preprocessing.py menjadi satu-satunya pintu masuk teks untuk seluruh proyek. Empat fungsinya membentuk rantai:

FungsiMasukanKeluaranTugas
bersihkan_teksteks mentahteks bersihlowercase, buang tanda baca dan angka
tokenisasiteks bersihdaftar tokenpecah berdasarkan spasi
bangun_vocabdaftar teks latihkamus token → idurut frekuensi, sisipkan <pad> dan <unk>
teks_ke_idssatu teks + vocabderet id seragamtruncation, padding, kata asing → <unk>

Dua token khusus wajib ada: <pad> ber-id 0 menyeragamkan panjang teks dalam satu batch, sedangkan <unk> ber-id 1 menampung kata yang tidak ada di vocabulary saat model dipakai pada teks baru. Modulnya:

python Modul preprocessing (src/preprocessing.py)
"""Preprocessing teks ulasan: pembersihan, tokenisasi, vocabulary, encoding.

Empat tahap yang disediakan modul ini:
1. bersihkan_teks  -- lowercase + buang tanda baca dan angka.
2. tokenisasi      -- pecah teks bersih menjadi daftar token.
3. bangun_vocab    -- susun kamus token -> id dari data latih saja.
4. teks_ke_ids     -- ubah teks menjadi deret id dengan padding seragam.
"""

import csv
import re
from pathlib import Path

TOKEN_PAD = "<pad>"
TOKEN_UNK = "<unk>"
ID_PAD = 0
ID_UNK = 1

BERKAS_DATA = Path(__file__).resolve().parent.parent / "data" / "ulasan.csv"

_BERSIH_RE = re.compile(r"[^a-z\s]+")


def bersihkan_teks(teks):
    """Kembalikan teks lowercase tanpa tanda baca dan angka."""
    kecil = teks.lower()
    bersih = _BERSIH_RE.sub(" ", kecil)
    return " ".join(bersih.split())


def tokenisasi(teks):
    """Pecah teks menjadi daftar token kata."""
    return bersihkan_teks(teks).split()


def bangun_vocab(daftar_teks):
    """Susun vocabulary dari daftar teks, urut frekuensi lalu abjad.

    Dua token khusus selalu berada di depan: <pad> (id 0) untuk penyeragaman
    panjang dan <unk> (id 1) untuk kata yang belum pernah dilihat.
    """
    frekuensi = {}
    for teks in daftar_teks:
        for token in tokenisasi(teks):
            frekuensi[token] = frekuensi.get(token, 0) + 1
    vocab = {TOKEN_PAD: ID_PAD, TOKEN_UNK: ID_UNK}
    for token, jumlah in sorted(frekuensi.items(), key=lambda x: (-x[1], x[0])):
        vocab[token] = len(vocab)
    return vocab


def panjang_maksimum(daftar_teks):
    """Hitung jumlah token terbanyak pada satu teks."""
    return max(len(tokenisasi(teks)) for teks in daftar_teks)


def teks_ke_ids(teks, vocab, panjang_maks):
    """Ubah satu teks menjadi deret id berpanjang tetap `panjang_maks`.

    Teks lebih panjang dipotong (truncation); teks lebih pendek ditambah
    token <pad> (padding). Kata di luar vocabulary menjadi id <unk>.
    """
    ids = [vocab.get(t, ID_UNK) for t in tokenisasi(teks)]
    ids = ids[:panjang_maks]
    ids = ids + [ID_PAD] * (panjang_maks - len(ids))
    return ids


def muat_ulasan(berkas=BERKAS_DATA):
    """Baca CSV ulasan menjadi daftar pasangan (teks, label string)."""
    data = []
    with open(berkas, newline="", encoding="utf-8") as f:
        pembaca = csv.DictReader(f)
        for baris in pembaca:
            data.append((baris["ulasan"], baris["label"]))
    return data


if __name__ == "__main__":
    data = muat_ulasan()
    label_latih = [t for t, _ in data[:240]]

    print("contoh pembersihan:")
    for mentah in ["Pengiriman SANGAT cepat!!!", "Barangnya rusak (lagi)..."]:
        print(f"  '{mentah}' -> '{bersihkan_teks(mentah)}'")

    vocab = bangun_vocab(label_latih)
    print(f"\ntotal ulasan {len(data)} | ukuran vocabulary {len(vocab)}")
    print("5 token teratas:", list(vocab.items())[:7])

    pm = panjang_maksimum(label_latih)
    contoh = teks_ke_ids("pengiriman sangat cepat", vocab, pm)
    print(f"\npanjang maksimum token: {pm}")
    print("ids 'pengiriman sangat cepat':", contoh)
    oov = teks_ke_ids("aplikasinya mudah dipakai", vocab, pm)
    print("ids teks kata asing      :", oov)

Output yang Dihasilkan:

text
contoh pembersihan:
  'Pengiriman SANGAT cepat!!!' -> 'pengiriman sangat cepat'
  'Barangnya rusak (lagi)...' -> 'barangnya rusak lagi'

total ulasan 300 | ukuran vocabulary 90
5 token teratas: [('<pad>', 0), ('<unk>', 1), ('produk', 2), ('pengiriman', 3), ('dan', 4), ('ini', 5), ('tokonya', 6)]

panjang maksimum token: 7
ids 'pengiriman sangat cepat': [3, 15, 24, 0, 0, 0, 0]
ids teks kata asing      : [1, 1, 1, 0, 0, 0, 0]

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 24-28 (bersihkan_teks): regular expression [^a-z\s]+ menggantikan setiap karakter selain huruf kecil dan spasi menjadi satu spasi; pemanggilan " ".join(...) merapatkan spasi ganda sisa pembersihan.
  2. Baris 36-47 (bangun_vocab): frekuensi dihitung dari daftar teks yang diberikan — kelak hanya data latih — lalu token diurutkan menurun frekuensi dengan tie-break abjad agar kamus deterministik.
  3. Baris 57-67 (teks_ke_ids): teks lebih panjang dari panjang_maks dipotong, teks lebih pendek ditambah id 0, dan kata di luar kamus menjadi id 1 — tiga perlakuan yang membuat tensor batch berbentuk seragam.
  4. Baris 69-75 (muat_ulasan): pembacaan CSV memakai csv.DictReader sehingga urutan kolom di berkas tidak memengaruhi kode.

Perhatikan keluaran terakhir: teks “aplikasinya mudah dipakai” seluruhnya menjadi id 1 karena kata-katanya tidak pernah muncul pada 240 data latih. Perilaku ini normal untuk vocabulary kecil dan menjadi alasan dataset latih harus seluas mungkin.

Uji Modul Preprocessing #

Setiap modul proyek ini dilengkapi test berbasis assert murni tanpa dependensi eksternal. Test preprocessing memeriksa pembersihan, urutan vocabulary, dan tiga perlakuan teks_ke_ids:

python Test preprocessing (tests/test_preprocessing.py)
"""Test untuk modul preprocessing (pembersihan, tokenisasi, vocabulary).

Jalankan: python3 test_preprocessing.py
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

from preprocessing import (  # noqa: E402
    ID_PAD,
    ID_UNK,
    bangun_vocab,
    bersihkan_teks,
    muat_ulasan,
    panjang_maksimum,
    teks_ke_ids,
    tokenisasi,
)


def test_bersihkan_teks():
    assert bersihkan_teks("Pengiriman SANGAT cepat!!!") == "pengiriman sangat cepat"
    assert bersihkan_teks("  Barang   rusak (lagi)... ") == "barang rusak lagi"
    assert bersihkan_teks("pesan nomor 123") == "pesan nomor"


def test_tokenisasi():
    assert tokenisasi("Kualitasnya Bagus") == ["kualitasnya", "bagus"]
    assert tokenisasi("") == []


def test_bangun_vocab():
    vocab = bangun_vocab(["produk bagus", "produk rusak", "produk bagus"])
    assert vocab["<pad>"] == ID_PAD == 0
    assert vocab["<unk>"] == ID_UNK == 1
    # urutan penyusunan: frekuensi menurun, lalu abjad pada frekuensi sama
    assert vocab["produk"] < vocab["bagus"]   # 3 kemunculan vs 2
    assert vocab["bagus"] < vocab["rusak"]    # 2 kemunculan vs 1
    assert len(vocab) == 5


def test_teks_ke_ids():
    vocab = bangun_vocab(["produk bagus sekali"])
    ids = teks_ke_ids("produk bagus", vocab, 4)
    assert len(ids) == 4
    assert ids[2] == ID_PAD and ids[3] == ID_PAD      # padding di ujung
    oov = teks_ke_ids("kata asing", vocab, 2)
    assert oov == [ID_UNK, ID_UNK]                     # kata di luar vocab
    panjang = teks_ke_ids("produk bagus sekali", vocab, 2)
    assert panjang == [vocab["produk"], vocab["bagus"]]  # truncation


def test_data_nyata():
    data = muat_ulasan()
    assert len(data) == 300
    label = {l for _, l in data}
    assert label == {"positif", "netral", "negatif"}
    teks_latih = [t for t, _ in data[:240]]
    assert panjang_maksimum(teks_latih) == 7


if __name__ == "__main__":
    test_bersihkan_teks()
    test_tokenisasi()
    test_bangun_vocab()
    test_teks_ke_ids()
    test_data_nyata()
    print("SEMUA TEST LULUS")

Output yang Dihasilkan:

text
SEMUA TEST LULUS

Data dan preprocessing kini berada dalam bentuk yang bisa dikonsumsi model. Bagian berikutnya merakit arsitektur yang membaca deret id tersebut: tabel embedding, perangkum mean pooling, dan classifier linear.