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:
| Kelas | Sumber templat | Contoh kata kunci |
|---|---|---|
| positif | pujian produk, pengiriman, pelayanan | bagus, mantap, memuaskan, awet |
| netral | pertanyaan dan pernyataan faktual | tersedia, bertanya, resi, memesan |
| negatif | keluhan produk, pengiriman, kemasan | buruk, 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:
"""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:
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 sekaliMari kita telaah kode di atas baris per baris:
- Baris 16-18 (seed dan jumlah):
SEED = 42mengunci seluruh pemilihan templat dan kata;JUMLAH_PER_KELAS = 100menghasilkan 300 baris seimbang sehingga akurasi adalah metrik yang adil. - Baris 65-83 (
bangkitkan_ulasan): setiap kelas mengambil kata dari kosakatanya sendiri, memilih templat secara acak, lalu mengisi placeholder{kata}dan{nama}. Pemanggilanacak.shuffledi akhir memastikan tiga kelas tercampur sebelum ditulis ke CSV. - Baris 86-100 (
main):csv.writermenulis kepalaulasan,labeldiikuti 300 baris data;BERKAS_OUTPUTdihitung 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:
| Fungsi | Masukan | Keluaran | Tugas |
|---|---|---|---|
bersihkan_teks | teks mentah | teks bersih | lowercase, buang tanda baca dan angka |
tokenisasi | teks bersih | daftar token | pecah berdasarkan spasi |
bangun_vocab | daftar teks latih | kamus token → id | urut frekuensi, sisipkan <pad> dan <unk> |
teks_ke_ids | satu teks + vocab | deret id seragam | truncation, 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:
"""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:
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:
- 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. - 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. - Baris 57-67 (
teks_ke_ids): teks lebih panjang daripanjang_maksdipotong, teks lebih pendek ditambah id 0, dan kata di luar kamus menjadi id 1 — tiga perlakuan yang membuat tensor batch berbentuk seragam. - Baris 69-75 (
muat_ulasan): pembacaan CSV memakaicsv.DictReadersehingga 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:
"""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:
SEMUA TEST LULUSData 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.