Eksplorasi Front-End

Setelah membuat model untuk task named entity, membuat model server, membuat web service maka selanjutnya adalah membuat web untuk mendemokan API yang dibuat.

Saya terakhir mengurusi front-end hampir 10 thn yang lalu untuk penelitian bersama pembimbing S2 saya. Waktu itu saya jadi belajar CSS dan Javascript. Saat lihat-lihat kondisi sekarang, wah sudah jauh berbeda. Ada berbagai macam framework: React, Angular, Vue.

Setelah saya eksplorasi, ternyata jauh berbeda dengan pendekatan lama.  Perlu belajar banyak. Masalahnya, framework juga bergeser dengan cepat. Bahkan untuk framework yang sama bisa mengalami perubahan besar (seperti Angular).  Kombinasi yang buruk terutama bagi saya yang punya waktu terbatas: perlu sumberdaya besar untuk belajar + gampang kadaluarsa ilmunya.

Akhirnya saya buat dengan cara lama saja, Python+Flask+Jinja2 +JQueryUI dan Bootstrap. Mungkin kalau benar-benar perlu serius, kami akan sewa orang lain saja 🙂

 

Iklan

Dockerized Model Server

Posting saya sebelumnya tentang model server

Hal yang harus dilakukan berikutnya adalah deploy model server ini. Pengalaman saya sebelumnya,  deployment bisa jadi hal yang merepotkan karena harus install aplikasi, install library, setting parameter dan sebagainya.  Banyak app lama yang malas saya sentuh karena ini. Saya langsung tertarik setelah membaca Docker, karena akan sangat memudahkan bagi saya yang males ini hehe.

Rencananya, setiap task akan menjadi container yang terpisah. Jadi akan ada container untuk NER (named entity recognition), deteksi 5W1h (what, where, dsb),  paraphrase, similarity, aspect based sentiment analysis dsb. Lalu ada container untuk web service sebagai penghubung model server dengan dunia luar.  Saya menggunakan image dari https://github.com/tiangolo/uwsgi-nginx-flask-docker untuk webservice (flask, uwsgi dan nginx).

Saat saya coba menjalankan dua container (model server NER dan web service), sempat terbentur masalah. Pertama, di model server yang menggunakan socketserver,  tidak bisa menggunakan “localhost” tetapi 0.0.0.0 (masalah binding?).  Kedua,  container web service ternyata tidak dapat menghubungi model server (masalah komunikasi antar dua container). Solusinya saat container web service dijalankan, tambahkan parameter run –add-host=parent-host:`ip route show | grep docker0 | awk ‘{print \$9}’`

Solusi yang lebih elegan adalah menggunakan docker compose. Jadi di docker-compose.yml isinya seperti ini untuk mendefinisikan web service dan model server:

version: "3"
services:
  web:
    build: .
    ports:
      - "5000:80"
  ner5w1h:
    image: modelserverner5w1h
    ports:
      - "6200:6200"

Setelah itu di web service, nama host bisa langsung menggunakan nama container yang dituju.

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect(("ner5w1h", 6200))  # gunakan nama sesuai docker-compose.yml
        s.sendall(words.encode('utf-8'))
        data = s.recv(1024)
        s.close
    return data.decode('utf-8')

Catatan lain tentang docker yang saya temui:

  1. Untuk melihat isi image, gunakan “docker run -it namaimage sh”.  Ini gara-gara saya kira opsi “ADD model” akan otomatis membuat isi direktori /model (ternyata cuma copy dalamnya saja). Harusnya “ADD model /model”.
  2. Untuk melihat isi log gunakan  “docker logs -f namacontainer”. Sedangkan code untuk loggingnya adalah sebagai berikut (cmiiw):
def get_module_logger(mod_name):
    """
    penggunaan: get_module_logger(__name__).info("mulai...")
    """
    logger = logging.getLogger(mod_name)
    handler = logging.StreamHandler()
    formatter = logging.Formatter(
        '%(asctime)s [%(name)-12s] %(levelname)-8s %(message)s')
    handler.setFormatter(formatter)
    logger.addHandler(handler)
    logger.setLevel(logging.DEBUG)
    return logger

docker-compose cocok untuk multi docker pada satu host. Jika sudah melibatkan banyak host, katanya dianjurkan menggunakan kubernetes. Tapi untuk sekarang cukup dulu 🙂

Contoh hasilnya: (silakan ganti isi parameter kalimat)

http://nlp.yuliadi.pro:5000/ner?kalimat=Jokowi%20dan%20Jusuf%20Kalla%20pergi%20ke%20Jakarta

Langkah berikutnya adalah membuat frontend untuk demo API.

Dependency Parsing Bahasa Indonesia dengan Lib UUParser

Update: demo visualisasi dependency dapat dilihat di: http://nlp.yuliadi.pro/ (tab ke-3)

Salah satu cara memandang struktur sebuah kalimat adalah dengan dependency grammar. Pada  dependency grammar,  setiap kata dianggap bergantung dengan kata yang lain. Sebagai contoh untuk kalimat “Ibu pergi ke pasar”,  maka kata “pergi” adalah akar kalimat (root), kata “ibu” dan “pasar” bergantung pada kata “pergi” dan kata “ke” bergantung pada “pasar”.  Dengan dependency grammar makna suatu kalimat dapat lebih mudah dipahami (oleh komputer). Misalnya kalimat: “Budi mempelajari ikan dari udara”.  Kalimat ini dapat memiliki dua arti, “[mempelajari ikan] dari udara” atau “mempelajari [ikan dari udara]”. Manusia  dengan cepat dapat menentukan makna yang benar adalah yang “[mempelajari ikan] dari udara” karena manusia memiliki konteks dan pengetahuan umum (tidak masuk akal ada ikan dari udara).

Dengan dependency grammar, kalimat tersebut dapat digambarkan seperti gambar berikut. Panah menunjukan arah ketergantungan. Gambar kedua memperlihatkan struktur dalam bentuk pohon.  Dapat dilihat bahwa kata udara bergantung pada mempelajari, bukan ikan. Catatan: salah buat gambar, harusnya “ikan” bukan “burung” 🙂

dep_grammar

Hubungan dependency  juga dapat dirinci maknanya, misalnya pada gambar di atas, hubungan antara Budi-mempelajari adalah nsubj = subyek. Hubungan yang lain adalah obj= “objek”, obl = “oblique argumentdsb. Rincian relasi yang digunakan universal dependency dapat dilihat di: http://universaldependencies.org/u/dep/ .    Universal dependency adalah proyek yang menyediakan  dataset POS-Tag, dependency dan morfologi untuk banyak bahasa, termasuk Bahasa Indonesia. Dataset ini yang akan kita gunakan untuk membuat model.

Penjelasan lebih lanjut tentang dependency grammar bisa dilihat di (materi dari Stanford): https://web.stanford.edu/~jurafsky/slp3/14.pdf  dan videonya: https://youtu.be/PVShkZgXznc 

Sebelumnya saya sudah membahas mengenai dependency parsing dengan Syntaxnet. Tapi Syntaxnet ini menggunakan lib  dalam bahasa C, yang  lebih sulit untuk diinstall dan lebih sulit  dimodifikasi. Saya ingin mencoba lib lain yang lebih sederhana.

Alternatif pertama adalah parser T. Dozat  https://github.com/tdozat/Parser-v1   tetapi saya kesulitan untuk menentukan lib yang diperlukan lib parser ini. Ternyata untuk proyek Python penting untuk menyebutkan proyek tersebut menggunakan lib apa saja. Banyak nama lib yang sama (tidak ada namaspace seperti package di Java), atau mungkin karena saya tidak paham saja.

Kemudian saya menemukan lib lain, BIST parser: https://github.com/elikip/bist-parser yang menggunakan Dynet dengan teknik transition based dan biLSTM. Sempat lihat-lihat Dynet, menarik juga, pendekatannya berbeda dengan Tensorflow dan sepertinya lebih mudah dipahami.  Ada beberapa code di BIST parser yang perlu disesuaikan karena masih menggunakan Dynet versi lama.

Terakhir saya lihat lib UUParser (https://github.com/UppsalaNLP/uuparser) yang merupakan fork dari BIST-parser. Codenya lebih baru dan terlihat ada usaha merapikan code BIST-parser. Jadi saya memilih lib ini. (lib ini menggunakan Python 2.7)

Pertama siapkan data training dari Universal Dependencies (http://universaldependencies.org/), pilih dan download dataset Bahasa Indonesia. Pilih dataset GSD yang memiliki data train, dev dan test.

Berdasarkan data tersebut, lakukan training sesuai petunjuk yang ada di https://github.com/UppsalaNLP/uuparser. Tanpa modifikasi parameter, hasilnya sudah lebih baik dari Syntaxnet. Nilai UAS dan LAS masing-masing 81.97 dan 75.37 (Syntaxnet: 80 dan 73). Mungkin nanti bisa dioptimasi lagi, cuma  bagi saya sudah cukup untuk sekarang.

Saya tambahkan sedikit code supaya bisa memproses input string, bukan file. Berikut codenya. Jangan lupa  tambah utils.read_conll_kalimat yang merupakan modifikasi dari utils.read_conll  (tadinya memproses file jadi memproses list teks). Isi variabel modeldir, modelfile, param sesuai hasil training.


from arc_hybrid import ArcHybridLSTM
import pickle, utils, os
# sesuaikan
modelDir="/home/yudiwbs/dataset/model/uuparser/id"
params="/home/yudiwbs/dataset/model/uuparser/id/params.pickle"
modelFile="barchybrid.model"

with open(params, 'r') as paramsfp:
    words, w2i, pos, rels, cpos, langs, stored_opt, ch = pickle.load(paramsfp)
    parser = ArcHybridLSTM(words, pos, rels, cpos, langs, w2i,
                           ch, stored_opt)
    model = os.path.join(modelDir, modelFile)
    parser.Load(model)

    kalimatStr = "Ibu pergi ke pasar"
    kalimat = [str(counter+1) + "\t" + kal for counter, kal in enumerate(kalimatStr.split())]
    kalimat.append("")

    data = utils.read_conll_kalimat(kalimat)
    pred = list(parser.Predict(data))
    for p in pred:
        print(p)

Hasilnya:

0	*root*	*root*	ROOT-CPOS	ROOT-POS	_	-1	rroot	_	_
1	Ibu	_	_	_	_	2	nsubj	_	_
2	pergi	_	_	_	_	0	root	_	_
3	ke	_	_	_	_	4	case	_	_
4	pasar	_	_	_	_	2	obl	_	_

Model Server dengan Python Multiprocess dan SocketServer

Model server adalah server yang melayani berbagai task machine learning. Saya tertarik untuk membuat ini untuk keperluan riset tapi mungkin juga bisa jadi komersial. Tidak semua perusahaan punya sumberdaya ahli NLP dan machine learning untuk membuat model yang bagus.    Menyewa konsultan juga bukan pilihan karena khawatir data dan strategi perusahaan bocor ke luar.  Web API bisa jadi alternatif yang cocok.

Beberapa alternatif lib untuk  model server adalah  TensorServing dan Clipper.  Saya lihat kok terlalu rumit untuk model sederhana yang saya gunakan, jadi saya mau coba buat sendiri. Ternyata memang susah, hampir dua minggu saya utak-atik baru berhasil hehe. Mungkin sebaiknya memang menggunakan lib tersebut. Berikut yang saya kerjakan (mungkin banyak yang tidak efisien karena saya baru belajar, silakan koreksi kalau ada alternatif lain).

Pertama karena saya ingin membuat Keras model + web service, dan saya sudah pernah mencoba flask (posting saya tentang flask),  maka saya mengikuti langkah-langkah di https://blog.keras.io/building-a-simple-keras-deep-learning-rest-api.html.   Pada posting tersebut model disimpan dalam variabel global. Ada masalah pada lib yang saya gunakan, ternyata tidak hanya model tapi kita perlu menyimpan juga graph tensorflow-nya. Berhasil saat dicoba, tapi begitu menggunakan uwsgi+Nginx kok jadi error. Ternyata variabel global yang digunakan di Flask tidak thread safe,  jadi selalu hilang. Alternatifnya adalah meload model setiap ada request, tapi tentu ini tidak feasible karena akan lambat dan menghabiskan memory.

Selanjutnya saya baca-baca tentang thread di Python, ternyata cukup lengkap, dan saya tertarik dengan multiprocessing, karena basisnya proses bukan thread harusnya  isi variabel bisa tetap tersimpan karena benar-benar terpisah. Idenya adalah kita buat multiprocess untuk membuat proses terpisah yang menyimpan model yang nanti diakses oleh web service (flask).    Bagus juga pemisahan model server dengan web server karena nanti kalau mau dibuat scalable, kita bisa lakukan dengan replikasi docker.

[Update Apr 2018:]
Cara yang lebih mudah (tanpa buat thread, tanpa queue) adalah dengan socketserver. Letakkan load model di main lalu proses klasifikasinya di handle.  Kenapa tidak terpikir dari awal ya? [endupdate]

Bagaimana menghubungkan antar proses yang memiliki model dan proses utama? saya menggunakan queue.  Ada dua queue, masing-masing untuk input dan output. Selanjutnya bagaimana menghubungkan antara  proses utama dengan web service? saya gunakan socket. Nah kesalahan saya adalah bermain di lib socketnya langsung. Ternyata susah, misalnya saya baru tahu bahwa saat kita mengirim, pihak penerima tidak tahu kapan harus berhenti dan harus dihandle manual (protocol TCP). Berhasil sih, tapi codenya jelek. Baru saat saya cari bagaimana membuat server yang bisa melayani multi client saya melihat lib socketserver  yang jauh lebih gampang digunakan hehe.

Berikut code servernya

import socketserver
from multiprocessing import Process, Queue
import os
import json

class MyTCPHandler(socketserver.BaseRequestHandler):

    def handle(self):
        # self.request is the TCP socket connected to the client
        self.data = self.request.recv(1024).strip()
        print("{} input:".format(self.client_address[0]))
        print(self.data)
        dataStr = self.data.decode('utf-8')
        q_input.put(dataStr)
        msg = q_output.get()  # baca output
        self.request.sendall(str.encode(msg))

def klasifikasi(q_input, q_output):

    # ========= load model disini, hanya satu kali saat method ini diload
    print("\nload model mulai.")
    # ========= load model

    # loop forever
    while True:
        msg = q_input.get()  # blocking menunggu data masuk
        print("input masuk, isinya=" + msg)
        hasil = model.klasifikasi(msg)  # proses klasifikasi atau apapun disini
        print("proses selesai, kirim ke queue output")
        q_output.put(json.dumps(hasil))

if __name__ == "__main__":
    q_input = Queue()
    q_output = Queue()
    p = Process(target=klasifikasi, args=(q_input, q_output))
    p.daemon = True
    p.start()  # mulai server 

    HOST, PORT = "localhost", 6200

    print("Server mulai...")
    server = socketserver.TCPServer((HOST, PORT), MyTCPHandler)
    server.serve_forever()

Selanjutnya server dapat ditest dengan command line seperti ini:

echo Golkar dan PDIP bergabung dalam pilkada 2019  | netcat localhost 6200

Atau dengan Ptyhon seperti ini, nanti tinggal dimasukkan ke dalam Flask

import socket

HOST = 'localhost'
PORT = 6200    # portnya harus sama
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b'Golkar dan PDIP bersatu dalam Pilkada 2019')
    data = s.recv(1024)
    print('Received', repr(data))
    s.close

Code ini belum diujicoba dengan akses bersamaan (apakah perlu ada id di dalam setiap request? bagaimana kalau pesan lebih dari 1024 byte?), jadi mungkin masih harus diperbaiki. Tapi setidaknya sudah bisa digunakan dengan flask+uswsgi+nginx.

GloVe untuk Wikipedia Bahasa Indonesia

Sebelumnya saya sudah membuat vector word2vec  Wikipedia Bhs Indonesia dengan Gensim. Posting ini akan membahas model embedded word yang lain yaitu GloVe.  Saya akan gunakan untuk task NER. Pengalaman saya dulu untuk task textual entailment bahasa Inggris, Glove lebih baik daripada Word2Vec.

Untuk GloVe, saya tidak menemukan implementasinya dalam Python, yang ada adalah dari penulisnya langsung dalam C.  Berikut langkah-langkahnya.

  1. Download source code dari https://nlp.stanford.edu/projects/glove/
  2. Ekstrak dan masuk ke directorynya, lalu ketik “make” untuk mem-build source code.
  3. Jalankan ./demo.sh   demo ini akan mendownload data text8 sekitar 30MB. Jika berhasil artinya program bisa kita gunakan.
  4. Siapkan file teks gabungan dari artikel wikipedia bahasa indonesia (posting saya tentang ini)
  5. Ubah demo.sh jadi seperti ini. File input ada di variabel CORPUS, file output ada di variabel SAVE_FILE.   Saya buang bagian download file dan bagian evaluasi.
#!/bin/bash

CORPUS=wiki.id.case.text
VOCAB_FILE=vocab.txt
COOCCURRENCE_FILE=cooccurrence.bin
COOCCURRENCE_SHUF_FILE=cooccurrence.shuf.bin
BUILDDIR=build
SAVE_FILE=glove_wiki_id_50
VERBOSE=2
MEMORY=4.0
VOCAB_MIN_COUNT=5
VECTOR_SIZE=50
MAX_ITER=15
WINDOW_SIZE=15
BINARY=2
NUM_THREADS=8
X_MAX=10

$BUILDDIR/vocab_count -min-count $VOCAB_MIN_COUNT -verbose $VERBOSE  $VOCAB_FILE
if [[ $? -eq 0 ]]
  then
  $BUILDDIR/cooccur -memory $MEMORY -vocab-file $VOCAB_FILE -verbose $VERBOSE -window-size $WINDOW_SIZE  $COOCCURRENCE_FILE
  if [[ $? -eq 0 ]]
  then
    $BUILDDIR/shuffle -memory $MEMORY -verbose $VERBOSE  $COOCCURRENCE_SHUF_FILE
    if [[ $? -eq 0 ]]
    then
       $BUILDDIR/glove -save-file $SAVE_FILE -threads $NUM_THREADS -input-file $COOCCURRENCE_SHUF_FILE -x-max $X_MAX -iter $MAX_ITER -vector-size $VECTOR_SIZE -binary $BINARY -vocab-file $VOCAB_FILE -verbose $VERBOSE

    fi
  fi
fi

Untuk mengetest hasilnya, kita bisa gunakan code sebelumnya karena Gensim bisa mengkonversi file GloVe.

Konversi dari Glove ke word2vec (diambil dari: https://radimrehurek.com/gensim/scripts/glove2word2vec.html)

from gensim.test.utils import datapath, get_tmpfile
from gensim.models import KeyedVectors
from gensim.scripts.glove2word2vec import glove2word2vec

namaFileGlove = "glove_wiki_id.txt"
glove_file = datapath(namaFileGlove)
tmp_file = get_tmpfile("w2vec_glove_wiki_id.txt")

glove2word2vec(glove_file, tmp_file)

Sekarang kita coba dengan code yang sama dengan Word2Vec sebelumnya (untuk load gunakan KeyedVectors.load_word2vec_format). Supaya sama, saya gunakan ukuran VECTOR_SIZE=400, walaupun prosesnya jadi lebih lama dan filenya lebih besar.

from gensim.models import KeyedVectors
namaFileModel = "w2vec_glove_wiki_id.txt"
model = KeyedVectors.load_word2vec_format(namaFileModel)
hasil = model.most_similar("Bandung")
print("Bandung:{}".format(hasil))
hasil = model.most_similar("tempo")
print("tempo:{}".format(hasil))
hasil = model.most_similar("Tempo")
print("Tempo:{}".format(hasil))
hasil = model.most_similar("Soekarno")
print("Soekarno:{}".format(hasil))

sim = model.similarity("bakso", "nasi")
print("Kedekatan bakso-nasi: {}".format(sim))
sim = model.similarity("bakso", "pecel")
print("Kedekatan bakso-pecel: {}".format(sim))
sim = model.similarity("bakso", "mobil")
print("Kedekatan bakso-mobil: {}".format(sim))

hasil = model.most_similar_cosmul(positive=['perempuan', 'raja'], negative=['pria'])
print("pria-raja, perempuan-?: {}".format(hasil))

hasil = model.most_similar_cosmul(positive=['perempuan', 'raja'], negative=['lelaki'])
print("lelaki-raja, perempuan-?:{}".format(hasil))

hasil = model.most_similar_cosmul(positive=['minuman', 'mangga'], negative=['buah'])
print("buah-mangga, minuman-?:{}".format(hasil))

Hasilnya sebagai berikut

Bandung:[('Bogor', 0.5553832650184631), ('Surabaya', 0.5533844232559204), ('Jakarta', 0.5264717936515808), ('Medan', 0.5121393203735352), ('Semarang', 0.4910121262073517), ('Yogyakarta', 0.4880320131778717), ('Malang', 0.48358896374702454), ('Jawa', 0.4750467836856842), ('ITB', 0.4737907946109772), ('Persib', 0.4654899537563324)]
tempo:[('indonesiana', 0.5886592268943787), ('doeloe', 0.5427557229995728), ('putu_suasta', 0.48804518580436707), ('tapin', 0.46188244223594666), ('https', 0.41826149821281433), ('cepat', 0.40567928552627563), ('ketukan', 0.4037955701351166), ('irama', 0.3982717990875244), ('lambat', 0.39812949299812317), ('maestoso', 0.39417707920074463)]
Tempo:[('Majalah', 0.54466712474823), ('Koran', 0.5328548550605774), ('Doeloe', 0.5282064080238342), ('majalah', 0.4538464844226837), ('Kompas', 0.4463438391685486), ('wartawan', 0.4179822504520416), ('koran', 0.41709277033805847), ('Harian', 0.40668201446533203), ('Republika', 0.3915051221847534), ('Post', 0.38742369413375854)]
Soekarno:[('Hatta', 0.6839763522148132), ('Soeharto', 0.5900896787643433), ('Sukarno', 0.5895135998725891), ('Bung', 0.49154624342918396), ('Vannico', 0.4613707363605499), ('Megawati', 0.46065616607666016), ('Karno', 0.4603942334651947), ('Presiden', 0.4588601887226105), ('Ekki', 0.45219823718070984), ('WIII', 0.4458869993686676)]
Kedekatan bakso-nasi: 0.33218569528946
Kedekatan bakso-pecel: 0.3385669314106577
Kedekatan bakso-mobil: 0.1036423556873547
pria-raja, perempuan-?: [('Raja', 0.8700850605964661), ('kerajaan', 0.8684984445571899), ('Yehuda', 0.8591107130050659), ('cucu', 0.8312298059463501), ('AbiMilki', 0.821474552154541), ('memerintah', 0.8194707632064819), ('saudara', 0.8159937262535095), ('Daud', 0.8155518770217896), ('Kerajaan', 0.8149770498275757), ('penguasa', 0.8049719333648682)]
lelaki-raja, perempuan-?:[('Raja', 0.9214608669281006), ('kerajaan', 0.919419527053833), ('Kerajaan', 0.8668190240859985), ('AbiMilki', 0.8551638722419739), ('ratu', 0.8542945384979248), ('penguasa', 0.8345737457275391), ('terakhir', 0.8345482349395752), ('disebutkan', 0.8269140720367432), ('istana', 0.82608562707901), ('istri', 0.8246856331825256)]
buah-mangga, minuman-?:[('beralkohol', 0.7880735397338867), ('Schorle', 0.7836616039276123), ('bersoda', 0.7783095240592957), ('manggaan', 0.7711527943611145), ('jeruk', 0.7603545784950256), ('anggur', 0.7549997568130493), ('Minuman', 0.7476464509963989), ('Frappuccino', 0.740592360496521), ('jahe', 0.7360817790031433), ('mocha', 0.7357983589172363)]

Penasaran, berikut hasil kalau vector_size-nya 50 (default)

Bandung:[('Surabaya', 0.8777784109115601), ('Malang', 0.8505295515060425), ('Jakarta', 0.8406218886375427), ('Medan', 0.8344693183898926), ('Semarang', 0.8225082159042358), ('Yogyakarta', 0.8207614421844482), ('Bogor', 0.8181610703468323), ('Makassar', 0.7571447491645813), ('Tangerang', 0.7515754699707031), ('Solo', 0.7264706492424011)]
tempo:[('doeloe', 0.7084428668022156), ('indonesiana', 0.6802346706390381), ('read', 0.6363065242767334), ('pas', 0.6065201759338379), ('indonesia', 0.5810031890869141), ('pda', 0.5744251608848572), ('putu_suasta', 0.5698538422584534), ('nada', 0.5527507066726685), ('html', 0.5519558787345886), ('irama', 0.5514932870864868)]
Tempo:[('Koran', 0.8052877187728882), ('Majalah', 0.7781724333763123), ('Kompas', 0.7708441019058228), ('Gramedia', 0.7339286208152771), ('Penerbit', 0.7299134731292725), ('Harian', 0.7244901657104492), ('Republika', 0.7203424572944641), ('koran', 0.7195203900337219), ('KOMPAS', 0.7062090635299683), ('Doeloe', 0.7039147615432739)]
Soekarno:[('Hatta', 0.876067042350769), ('Sukarno', 0.8076358437538147), ('Soeharto', 0.7557047605514526), ('Bung', 0.7302334308624268), ('kemerdekaan', 0.7065078616142273), ('Karno', 0.6804633736610413), ('Basuki', 0.6803600788116455), ('Kemerdekaan', 0.6702237129211426), ('Yudhoyono', 0.6673594117164612), ('Susilo', 0.6618077754974365)]
Kedekatan bakso-nasi: 0.6207393500954625
Kedekatan bakso-pecel: 0.5784330569151002
Kedekatan bakso-mobil: 0.28361517810153536
pria-raja, perempuan-?: [('Yehuda', 0.9973295331001282), ('memerintah', 0.9838510155677795), ('Herodes', 0.9673323631286621), ('Raja', 0.9654756784439087), ('Daud', 0.9616796970367432), ('putranya', 0.9616104960441589), ('kerajaan', 0.9497379660606384), ('cucu', 0.9484671950340271), ('Firaun', 0.947074830532074), ('menantu', 0.9469170570373535)]
lelaki-raja, perempuan-?:[('kerajaan', 1.0183593034744263), ('memerintah', 1.0134179592132568), ('penguasa', 1.0113284587860107), ('Raja', 0.9971156716346741), ('Kerajaan', 0.9939565658569336), ('takhta', 0.9919894933700562), ('tahta', 0.9914684891700745), ('istana', 0.9877175092697144), ('kekuasaan', 0.983529269695282), ('MANURUNGNGE', 0.9810593128204346)]
buah-mangga, minuman-?:[('Arak', 1.0535870790481567), ('Crawlers', 0.9980041980743408), ('Carpet', 0.9971945285797119), ('Rimpang', 0.

Sepertinya untuk analogi lebih bagus Word2Vec. Berbeda dengan word2vec, Bakso-nasi lebih dekat dibandingkan bakso-pecel. Hasil kedekatan kata juga berbeda. Kalau lihat sekilas sepertinya lebih bagus Word2Vec, tapi saat saya coba untuk task NER, lebih bagus GloVe (naik dari 0.70 ke 0.72 untuk ukuran 50 sedangkan untuk ukuran vector 400 hasilnya hanya naik sedikit). Mungkin perlu buat dataset untuk evaluasi word embedding ini.

Update:
Jika mau men-train dokumen Bahasa Inggris di demo.sh ada fungsi untuk mengevaluasi, code pythonnya menggunakan Python2 dan lib numpy, jika ingin menggunakan virtualenv, langkahnya sbb:

masuk ke direktory Glove,
mkdir virtenv
virtualenv -p /usr/bin/python2 virtenv
source virtenv/bin/activate
pip install numpy

Update demo.sh sebelum pemanggilan evaluate:

source virtenv/bin/activate
python eval/python/evaluate.py

Word2Vec Wikipedia Bahasa Indonesia dengan Python Gensim

Update: posting tentang Glove (dalam beberapa task Glove lebih baik kinerjanya)

Sebelumnya saya sudah mengaplikasikan word2vec untuk wikipedia Bahasa Indonesia dengan lib DeepLearning4J.  Sekarang saya mau mencoba dengan  Python Gensim   untuk eksperimen NER yang saya lakukan sebelumnya dan nanti untuk task-task lain.

Download versi terakhir data Wikipedia Bahasa Indonesia di https://dumps.wikimedia.org/idwiki/latest/. Cari yang bernama “idwiki-latest-pages-articles.xml.bz2” Ternyata setelah dua tahun, sudah naik ukurannya dari 300anMB ke 450MB.

Data wiki tersebut berbentuk XML, untungnya, Gensim sudah menyediakan  fasilitas untuk mentrain data wikipedia 🙂 Tidak perlu lagi repot-repot melakukan praproses. Berikut code-nya (diambil dari http://textminingonline.com/training-word2vec-model-on-english-wikipedia-by-gensim dengan sedikit modifikasi, soalnya error saat saya coba)

from __future__ import print_function

import logging
import os.path
import sys

from gensim.corpora import WikiCorpus

program = os.path.basename(sys.argv[0])
logger = logging.getLogger(program)

logging.basicConfig(format='%(asctime)s: %(levelname)s: %(message)s')
logging.root.setLevel(level=logging.INFO)
logger.info("running %s" % ' '.join(sys.argv))

namaFileInput = "idwiki-latest-pages-articles.xml.bz2"
namaFileOutput = "wiki.id.case.text"

space = " "
i = 0

output = open(namaFileOutput, 'w')

# lower=False: huruf kecil dan besar dibedakan
wiki = WikiCorpus(namaFileInput, lemmatize=False, dictionary={}, lower=False)
for text in wiki.get_texts():
    output.write(' '.join(text) + '\n')
    i = i + 1
    if i % 10000 == 0:
        logger.info("Saved " + str(i) + " articles")

output.close()
logger.info("Finished Saved " + str(i) + " articles")

Hasil dari program di atas adalah satu file besar yang berisi gabungan dari seluruh artikel. Satu artikel menjadi satu baris.

Setelah itu pembuatan model:

import multiprocessing
import logging
import os.path
import sys
import multiprocessing
from gensim.models import Word2Vec
from gensim.models.word2vec import LineSentence

program = os.path.basename(sys.argv[0])
logger = logging.getLogger(program)

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s')
logging.root.setLevel(level=logging.INFO)
logger.info("running %s" % ' '.join(sys.argv))

namaFileInput = "wiki.id.case.text"
namaFileOutput = "w2vec_wiki_id_case"

model = Word2Vec(LineSentence(namaFileInput), size=400, window=5, min_count=5, workers=multiprocessing.cpu_count())

# trim unneeded model memory = use (much) less RAM
model.init_sims(replace=True)
model.save(namaFileOutput)

Total praproses dan training hanya memerlukan waktu sekitar 20 menit. Model berbentuk 3 file dengan ukuran total 3GB.

Berikut contoh penggunaannya, saya lihat sekilas lebih bagus daripada model yang saya buat sebelumnya.

namaFileModel = "w2vec_wiki_id_case"
model = gensim.models.Word2Vec.load(namaFileModel)
hasil = model.most_similar("Bandung")
print("Bandung:{}".format(hasil))

hasil = model.most_similar("tempo")
print("tempo:{}".format(hasil))
hasil = model.most_similar("Tempo")
print("Tempo:{}".format(hasil))
hasil = model.most_similar("Soekarno")
print("Soekarno:{}".format(hasil))

sim = model.similarity("bakso", "nasi")
print("Kedekatan bakso-nasi: {}".format(sim))
sim = model.similarity("bakso", "pecel")
print("Kedekatan bakso-pecel: {}".format(sim))
sim = model.similarity("bakso", "mobil")
print("Kedekatan bakso-mobil: {}".format(sim))

hasil = model.most_similar_cosmul(positive=['perempuan', 'raja'], negative=['pria'])
print("pria-raja, perempuan-?: {}".format(hasil))

hasil = model.most_similar_cosmul(positive=['perempuan', 'raja'], negative=['lelaki'])
print("lelaki-raja, perempuan-?:{}".format(hasil))

hasil = model.most_similar_cosmul(positive=['minuman', 'mangga'], negative=['buah'])
print("buah-mangga, minuman-?:{}".format(hasil))

Hasilnya seperti ini:

Bandung:[('Bogor', 0.7126705646514893), ('Cimahi', 0.6414915323257446), ('Malang', 0.6207837462425232), ('Tasikmalaya', 0.613646924495697), ('Sukabumi', 0.6094871163368225), ('Cianjur', 0.5948120355606079), ('Purwakarta', 0.5937869548797607), ('Surabaya', 0.5675963759422302), ('Semarang', 0.5648865699768066), ('Garut', 0.5606454014778137)]
tempo:[('temponya', 0.5657634735107422), ('ketukan', 0.5246042609214783), ('irama', 0.4841456115245819), ('bertempo', 0.48334914445877075), ('beat', 0.4792878329753876), ('hitungan', 0.4732634723186493), ('nadanya', 0.4489333927631378), ('durasi', 0.4449218511581421), ('melodi', 0.44300010800361633), ('birama', 0.4420506954193115)]
Tempo:[('Gatra', 0.6947425603866577), ('Republika', 0.6929212212562561), ('TEMPO', 0.6797916293144226), ('KOMPAS', 0.6778919100761414), ('DeTik', 0.6597755551338196), ('Doeloe', 0.6481266617774963), ('Interaktif', 0.6416338682174683), ('Tabloid', 0.6330038905143738), ('Sindo', 0.6202648282051086), ('Galamedia', 0.6029475927352905)]
Soekarno:[('Sukarno', 0.813288152217865), ('Soeharto', 0.7391608953475952), ('Megawati', 0.6650642156600952), ('Suharto', 0.6611993908882141), ('Hatta', 0.6327983736991882), ('SBY', 0.6301325559616089), ('Bung', 0.6262293457984924), ('Jokowi', 0.6140671968460083), ('Yudhoyono', 0.5906702876091003), ('Karno', 0.5696855187416077)]
Kedekatan bakso-nasi: 0.7018489840251805
Kedekatan bakso-pecel: 0.8051109496938957
Kedekatan bakso-mobil: 0.23248957716849333
pria-raja, perempuan-?: [('Firaun', 0.8730435967445374), ('Ahas', 0.8643887639045715), ('Yerobeam', 0.8635646104812622), ('menantu', 0.8627196550369263), ('Uruk', 0.8598164319992065), ('Ahasyweros', 0.8586885929107666), ('kerajaan', 0.8582508563995361), ('Yoas', 0.8566167950630188), ('penguasa', 0.8554568290710449), ('rajanya', 0.8552286624908447)]
lelaki-raja, perempuan-?:[('ratu', 0.8525097966194153), ('kerajaan', 0.8269591927528381), ('Firaun', 0.8241982460021973), ('kaisar', 0.8102306723594666), ('firaun', 0.8079080581665039), ('penguasa', 0.8032354116439819), ('Asyur', 0.7912278771400452), ('rajanya', 0.7909599542617798), ('kerajaannya', 0.7896854281425476), ('Salomo', 0.787830650806427)]
buah-mangga, minuman-?:[('Minuman', 0.9949955344200134), ('yoghurt', 0.9938222169876099), ('bir', 0.953018069267273), ('beralkohol', 0.9518982768058777), ('penganan', 0.9509921073913574), ('jahe', 0.9492940306663513), ('camilan', 0.9480324983596802), ('kola', 0.9406602382659912), ('yogurt', 0.9402189254760742), ('soju', 0.9373763203620911)]

Menariknya, Jakarta tidak masuk ke 10 kata yang terdekat dengan Bandung. Tempo dan tempo memperlihatkan pentingnya membedakan huruf besar dan kecil. Kata “bakso” paling dekat dengan “pecel” dibandingkan dengan “nasi” apalagi “mobil”. Untuk analogi klasik man-king;woman-?, ternyata penggunaan kata “pria” dan “lelaki” menghasilkan hasil berbeda.

NER (Named Entity Recognition) dengan anaGo (Python Keras)

Update: demo NER, http://nlp.yuliadi.pro

Update 6 Juni 2018: Anago mengupdate versi packagenya dan tidak compatible dengan versi sebelumnya.  Jika ingin sesuai posting ini,  install dengan versi lama: pip3 install anago==0.0.5.   Saya belum eksplorasi versi anago yang terakhir.

Update: paper yang saya+istri buat tentang ini

Sebelumnya saya sudah membahas NER Bahasa Indonesia dengan Stanford NER.  Kelemahan dari StanfordNER adalah lisensinya  dan menggunakan Java.  Posting ini akan membahas NER dengan Python-Keras dengan library anaGo (https://github.com/Hironsan/anago)  yang menggunakan teknik biLSTM+CRF dan berlisensi MIT yang lebih longgar.  Lib NLTK walaupun menyediakan NER untuk Bahasa Inggris, tetapi tidak menyediakan cara yang mudah untuk men-training bahasa lain.

Untuk training, saya menggunakan  data https://github.com/yohanesgultom/nlp-experiments/blob/master/data/ner/training_data.txt  yang dikonversi ke format BIO. Jadi misalnya

DKI LOCATION
Jakarta LOCATION

perlu diubah menjadi

DKI B-LOC
Jakarta I-LOC

Tambahkan file validasi (saya ambil dari data training, jangan lupa harus diacak terlebih dulu urutannya).

Catatan: penggunaan awalan label “B-” dan “I-” seperti B-LOC, I-LOC, B-PER, I-PER, B-ORG, I-ORG dan B-MISC, I-MISC wajib di anaGo karena di codenya mendeteksi “B-” dan “I-“. Sedangkan untuk bagian belakangnya bebas.

Karena teknik ini dasarnya sequence labeling bisa saja kita manfaatkan untuk task yang lain, misalnya untuk mendeteksi subyek dan obyek sehingga labelnya menjadi B-SUB, I-SUB, B-OBJ, I-OBJ dsb.

Selanjutnya install tensorflow dan keras (catatan saya tentang ini) lalu anago (bisa dengan pip3 anago).

Buat code berikut untuk training membuat model:

import anago
from anago.reader import load_data_and_labels
import os
import numpy as np
import random as rn

namaDir = "/media/yudiwbs/data/"

namaFileTrain = namaDir + "train.txt"
namaFileValid = namaDir + "valid.txt"
namaFileTest = namaDir + "test.txt"

# karena hasil tdk konsisten, random seednya diisi manual
os.environ['PYTHONHASHSEED'] = '0'
np.random.seed(42)
rn.seed(12345)
import tensorflow as tf
from keras import backend as K
tf.set_random_seed(1234)

# atur parameternya disini
model = anago.Sequence(char_emb_size=25, word_emb_size=100, char_lstm_units=25,
              word_lstm_units=100, dropout=0.5, char_feature=True, crf=True,
              batch_size=20, optimizer='adam', learning_rate=0.001,lr_decay=0.9,
              clip_gradients=5.0, max_epoch=30, early_stopping=True, patience=3,train_embeddings=True, max_checkpoints_to_keep=5, log_dir=None)

model.train(x_train, y_train, x_valid, y_valid)

print("\n\nEvaluasi Test:")
model.eval(x_test, y_test)

words = 'Budi Martami kuliah di UPI yang berlokasi di Bandung'.split()
print(model.analyze(words))

words = 'PDIP yang dikawal Megawati menang dalam Pilkada DKI Jakarta'.split()
print(model.analyze(words))

Hasilnya sebagai berikut:

{'entities': [{'score': 1.0, 'beginOffset': 0, 'text': 'Budi Martami', 'type': 'MISC', 'endOffset': 2}, {'score': 1.0, 'beginOffset': 4, 'text': 'UPI', 'type': 'LOC', 'endOffset': 5}, {'score': 1.0, 'beginOffset': 8, 'text': 'Bandung', 'type': 'LOC', 'endOffset': 9}], 'words': ['Budi', 'Martami', 'kuliah', 'di', 'UPI', 'yang', 'berlokasi', 'di', 'Bandung']}
{'entities': [{'score': 1.0, 'beginOffset': 0, 'text': 'PDIP', 'type': 'ORG', 'endOffset': 1}, {'score': 1.0, 'beginOffset': 3, 'text': 'Megawati', 'type': 'PER', 'endOffset': 4}, {'score': 1.0, 'beginOffset': 6, 'text': 'Pilkada DKI', 'type': 'MISC', 'endOffset': 8}, {'score': 1.0, 'beginOffset': 8, 'text': 'Jakarta', 'type': 'LOC', 'endOffset': 9}], 'words': ['PDIP', 'yang', 'dikawal', 'Megawati', 'menang', 'dalam', 'Pilkada', 'DKI', 'Jakarta']}
{'entities': [], 'words': ['Ibu', 'pergi', 'ke', 'pasar']}

“Budi Martami” dideteksi sebagai MISC (harusnya PERSON)
“UPI” dideteksi sebagai LOC (lokasi) (harusnya ORG)
“Bandung” sudah benar dideteksi sebagai LOC

“PDIP” sudah cocok dideteksi ORG
“Megawati”  juga sudah benar sebagai PERSON
“Pilkada DKI”  benar sebagai MISC (DKI bukan sebagai LOC di frasa ini)
“Jakarta” harusnya masuk ke frase “Pilkada DKI Jakarta” sebagai MISC, bukan LOC.

“Ibu pergi ke pasar”  sudah benar karena tidak ada named entity didalamnya.

Data trainingnya memang banyak membahas tentang berita politik sehinggga  lebih akurat untuk kalimat kedua dibandingkan yang pertama. Nanti data trainingnya perlu ditambah lebih banyak lagi.

Hasil terbaik yang saya dapatkan adalah F1 = 0.6837. Masih rendah,  mungkin bisa ditingkatkan dengan menggunakan pretrained Word2Vec atau GloVe

Update: untuk menyimpan model, gunakan:

 model.save(directory) 

parameternya bukan file tapi directory. Sedangkan untuk me-load model:

model = anago.Sequence().load(namaDirModel) 

Update: F1 naik menjadi 0.69-0.70 saat menggunakan Word2Vec dari data Wikipedia Bhs Indonesia (posting tentang w2vec).

Untuk menambahkan word embedding saat training:

import gensim

embeddings =  gensim.models.Word2Vec.load(namaFileW2Vec)
model = anago.Sequence(..param lain.., embeddings=embeddings)

Jangan lupa parameter word_emb_size disamakan dengan model embeddings-nya.

Update: menggunakan glove, F1 naik ke 0.71-0.72 (untuk vector size 50)
Code:

from anago.reader import load_data_and_labels,load_glove
embeddings = load_glove(namaFileVec)
model = anago.Sequence(..param lain.., embeddings=embeddings)

Update: ada bug di package Anago. Parameter patience ternyata di hardcode. Berikut perbaikannnya:

Di file metrics tambahkan parameter patience_nb

function def get_callbacks(log_dir=None, valid=(), tensorboard=True, eary_stopping=True, patience_nb=3):

lalu di code berikut passing parameter ini (tadinya di hardcode 3)

    if eary_stopping:
        callbacks.append(EarlyStopping(monitor='f1', patience=patience_nb, mode='max'))

lalu di trainer tambahkan parameter:

        # Prepare callbacks
        callbacks = get_callbacks(log_dir=self.checkpoint_path,
                                  tensorboard=self.tensorboard,
                              eary_stopping=self.training_config.early_stopping,
                                  patience_nb=self.training_config.patience,
                                  valid=(valid_steps, valid_batches, self.preprocessor))

di trainer juga optimizer adam sepertinya di hardcode.

=== END UPDATE ===