Wednesday, June 19, 2019

Implementasi Pengenalan Emosi dari Sinyal Wicara Berbasis Deep Learning dengan Keras

Tulisan ini agak panjang, membahas implementasi pengenalan emosi dari sinyal wicara dengan teknik deep learning. Perkakas yang akan kita pakai adalah Keras. Kode python yang dipakai sebenarnya bukan dari saya, namun saya modifikasi dari [1] dan [2]. Baiklah, mari kita mulai.

Ide

Ide dasar penelitian di bidang speech emotion recognition (pengenalan emosi dari sinyal wicara) adalah bahwa sinyal wicara mengandung informasi emosi dari pembicara. Contoh sederhana, suara orang marah akan sangat berbeda dengan suara orang sedih dan senang. Dengan mengenali (pola) suara orang marah, senang, sedih, dll, kita akan bisa mengenali emosi seseorang dari suaranya.  Berikut ilustrasi pengenalan emosi dari penelepon.
Pengenalan emosi penelepon secara otomatis oleh Artificial Intelligence (AI)
Sumber:https://medium.com/@alitech_2017/voice-based-emotion-recognition-framework-for-films-and-tv-programs-2a6abbb77242


Diagram Alir Sistem

Secara umum, sistem pengenalan emosi dari sinyal wicara terdiri dari dua blok utama (selain input dan output):
  1. Ekstraksi Fitur
  2. Klasifikasi
Kode yang kita buat pun juga disimpan dalam dua blok tersebut: save_feature.py dan ser_ravdess.py. Input system adalah speech dataset, dalam hal ini kita pakai dataset RAVDESS [3]. Output yang kita ingin kita capai adalah kategori emosi secara diskrit (01 = neutral, 02 = calm, 03 = happy, 04 = sad, 05 = angry, 06 = fearful, 07 = disgust, 08 = surprised).

Diagram blok pengenalan emosi dari sinyal wicara

Dataset

Dataset yang kita gunakan adalah "The Ryerson Audio-Visual Database of Emotional Speech and Song (RAVDESS)". Dataset ini sebenarnya tidak khusus untuk pengenalan emosi dari sinyal wicara, namun juga berisi video dan juga musik dari 24 aktor berbeda, berimbang secara gender. Dataset ini tersedia secara gratis dan open source, bisa didownload disini: https://zenodo.org/re-CCcord/1188976#.XQdLn5_jpEQ. Emosi yang digunakan pada perekaman dataset tersebut meliputi 8 emosi yang telah disebutkan sebelumnya. Label emosi ini ada pada nama file yang terdiri dari 7 item sebagai beikut:

AA-BB-CC-DD-EE-FF-GG

Dimana label emosi ada pada item ketiga, yakni CC. Jika CC bernilai 01 = neutral, 02 = calm, 03 = happy, 04 = sad, 05 = angry, 06 = fearful, 07 = disgust, 08 = surprised.

Pada komputer saya, direktori dataset yang nampak dengan perintah tree adalah sebagai berikut:
Audio_Speech_Actors_01-24
├── Actor_01
│   ├── 03-01-01-01-01-01-01.wav
│   ├── 03-01-01-01-01-02-01.wav
│   ├── 03-01-01-01-02-01-01.wav
│   ├── ...
│   └── 03-01-08-02-02-02-01.wav
├── Actor_02
│   ├── 03-01-01-01-01-01-02.wav
│   ├── 03-01-01-01-01-02-02.wav
│   ├── ...
│   └── 03-01-08-02-02-02-02.wav
├── Actor_03
│   ├── 03-01-01-01-01-01-03.wav
│   ├── 03-01-01-01-01-02-03.wav
│   ├── ...
│   └── 03-01-08-02-02-02-03.wav
├── Actor_04
│   ├── 03-01-01-01-01-01-04.wav
│   ├── 03-01-01-01-01-02-04.wav
│   ├── ...
│   └── 03-01-08-02-02-02-04.wav
├── ...
└── Actor_24
    ├── 03-01-01-01-01-01-24.wav
    ├── 03-01-01-01-01-02-24.wav
    ├── ...
    └── 03-01-08-02-02-02-24.wav

24 directories, 1440 files

Pada kode yang akan kita buat, lokasi dataset ini, lokasi direktori `Audio_Speech_Actors_01-24` di komputer saya akan berbeda dengan komputer anda. Saya biasa menyimpan dataset pada direktori `/media/bagustris/bagus/dataset/`. Anda perlu merubahnya pada skrip ekstraksi fitur di bawah ini.

Ekstraksi Fitur

File python pertama yang kita buat saya beri nama save_feature.py di bawah direktori ravdess_ser. Penamaan file ini sangat penting, begitu juga penamaan folder/direktori. Baca ini untuk lebih jelasnya. Oya, saya menggunakan jupyter-lab IPython untuk menulis dan mengeksekusi kode python karena kemampuannya bisa mengeksekusi per baris/blok baris. Saya sarankan anda untuk menggunakannya juga.

Pertama, seperti biasa, kita deklasarikan paket-paket yang kita gunakan.

import glob  
import os  
import librosa  
import numpy as np  

from keras.utils import to_categorical
import ntpath

Kita akan scan baris per baris di atas. Paket glob digunakan untuk menemukan filepath pada system Unix. Bersama dengan paket os, dua paket tersebut biasa dikombinasikan untuk mencari file/direktory pada suatu path. Dari glob kita memakai glob (glob.glob) untuk mendaftar semua wav file pada direktori dataset, dengan os kita menggabungkan subdirektori (Actor_XX) dengan main direktori (Audio_Speech_Actors_01-24). Librosa adalah library utama untuk ekstraksi fitur akustik. Numpy merupakan library python yang pasti dan selalu kita pakai. Keras, pada save_feature.py ini, digunakan untuk konversi nilai kategori (01-08) ke nilai diskrit (misal 01 menjadi [1 0 0 0 0 0 0]), dan npath digunakan untuk mendapatkan nama file (basename) dari tiap file wav.

Kode-kode selanjutnya setelah import module diatas adalah dua fungsi sebagai berikut:
# function to extract feature
def extract_feature(file_name):   
    X, sample_rate = librosa.load(file_name)  
    stft = np.abs(librosa.stft(X))  
    mfcc = np.mean(librosa.feature.mfcc(y=X, sr=sample_rate, n_mfcc=40).T,axis=0)  
    #mfcc_delta = np.mean(librosa.feature.delta(librosa.feature.mfcc(X, sample_rate, 40).T, axis=0))
    #mfcc_delta2 = np.mean(librosa.feature.delta(librosa.feature.mfcc(X, sample_rate, 40).T, axis=0), order=2)
    chroma = np.mean(librosa.feature.chroma_stft(S=stft, sr=sample_rate).T,axis=0)  
    mel = np.mean(librosa.feature.melspectrogram(X, sr=sample_rate).T,axis=0)  
    contrast = np.mean(librosa.feature.spectral_contrast(S=stft, sr=sample_rate).T,axis=0)  
    tonnetz = np.mean(librosa.feature.tonnetz(y=librosa.effects.harmonic(X), sr=sample_rate).T,axis=0)  
    return mfcc, chroma, mel, contrast, tonnetz #  mfcc_delta, mfcc_delta2, 

# function to parse audio file given main dir and sub dir
def parse_audio_files(parent_dir,sub_dirs,file_ext="*.wav"):
    features, labels = np.empty((0, 193)), np.empty(0)  #273: number of features, ori:193
    for label, sub_dir in enumerate(sub_dirs):
        for fn in glob.glob(os.path.join(parent_dir, sub_dir, file_ext)):
            try:
                print('process..', fn)
                mfcc, chroma, mel, contrast,tonnetz  = extract_feature(fn)
            except Exception as e:
                print('cannot open', fn)
                continue
            ext_features = np.hstack([mfcc, chroma, mel, contrast, tonnetz])
            #ext_features = np.hstack([mfcc, mfcc_delta, mfcc_delta2, chroma, mel, contrast,tonnetz ])
            features = np.vstack([features, ext_features])
            filename = ntpath.basename(fn)
            labels = np.append(labels, filename.split('-')[2])  # grab 3rd item
            #labels = np.append(labels, fn.split('/')[2].split('-')[1])
    return np.array(features), np.array(labels, dtype = np.int)

Pada beberapa baris dua fungsi, ada yang saya comment (#) karena tidak saya pakai. Namun, baris-baris tersebut sebelumnya saya pakai dan hasilnya saya sajikan/bahas di akhir tulisan ini. Baiklah, kita walk-in lebih detail denga kedua fungsi di atas.
1. Fungsi extract_feature
Ini adalah fungsi utama kita yang mengekstrak fitur akustik dari tiap file wav inputnya.
Input: wav file (baris 14)
Output: Fitur akustik dalam bentuk vektor dari satu file wav(baris 24)
Selanjutnya di antara baris 13 dan 25 tersebut dapat dijelaskan sebagai berikut ("dari" menunjukkan input dari baris/fungsi tersebut):

  • Baris 15: membaca file wav (in/dari: file_name, out: X, sample_rate)
  • Baris 16: Transformasi fourier dari X dengan fungsi dari librosa stft, disimpan sebagai STFT
  • Baris 17: Ekstraksi fitur MFCC dari X
  • Baris 18: Ekstraksi fitur delta MFCC (tidak digunakan)
  • Baris 19: Ekstraksi fitur delta-delta MFCC (tidak digunakan)
  • Baris 20: Ekstraksi fitur chromagram dari STFT
  • Baris 21: Ekstrasi fitur mel-scaled spectrogram dari X
  • Baris 22: Ekstraksi fitur contrast dari STFT [4]
  • Baris 22: Ekstraksi fitur tonnetz dari X [5]

2. Fungsi parse_audio_files
Fungsi ini digunakan untuk mengektrak fitur (dengan fungsi extract_feature di atas) diberikan nama main directory, subdirectory dan ekstensi (*.wav) sebagai inputnya (baris 27).
Output: Tumpukan array fitur akustik dari semua file dan labelnya (baris 43).

  • Baris 28: Membuat array kosong untuk menampung fitur dan label
  • Baris 29: Berjalan/scan pada jumlah file pada subdirektori. Untuk lebih jelasnya, perhatikan potingan kode berikut:
  • main_dir = '/media/bagustris/bagus/dataset/Audio_Speech_Actors_01-24/'
    sub_dir = os.listdir(main_dir)
    for label, sub in enumerate(sub_dir):
        print(label, sub)
    
    Output
    0 Actor_01
    1 Actor_02
    2 Actor_03
    3 Actor_04
    ...
    
  • Baris 30: Scan setiap file pada subdirektory di bawah direktori utama (main_dir)
  • Baris 31-33: Ekstrak sejumlah fitur dari file jika file ditemukan
  • Baris 34-36: Tampilkan error jika file tidak ditemukan
  • Baris 37: Tumpuk fitur secara horizontal untuk satu file
  • Baris 39: Tumpuk fitur secara vertikal untuk file saat ini dengan file sebelumnya (baris 37)
  • Baris 40: Ekstrak namafile tanpa path
  • Baris 41: Ekstrak label, yakni item ke-3 (python mengindeksnya sebagai 2, mulai dari 0) setelah tanda pemisah "-".
Setelah dua definisi fungsi di atas, kita tulis kode untuk mendefinisikan direktori utama dan subdirektori beserta pemanggilannya.

main_dir = '/media/bagustris/bagus/dataset/Audio_Speech_Actors_01-24/'
sub_dir = os.listdir(main_dir)  
print ("\ncollecting features and labels...")  
print("\nthis will take some time...")  
features, labels = parse_audio_files(main_dir,sub_dir)  
labels_oh = to_categorical(labels)   # one hot conversion from integer to binary
print("done") 

Penjelasan kode-kode di atas adalah sebagai berikut:

  • Baris 46: Definisi lokasi direktori utama dataset.
  • Baris 47: List semua subdirektori di bawah direktori utama dengan modul os.
  • Baris 50: Ekstraksi fitur dan label dari semua file di subdirektori dengan fungsi parse_audio_file (yang memanggil fungsi extract_feature di dalamnya).
  • Baris 51: Konversi label dari digit ke binary dengan Keras to_categorical.
Untuk mengecek hasil ekstraksi fitur (input) dan label (output) saya biasanya mencetak ukurannya. Ini penting karena input dan output harus sama ukurannya (barisnya).
# make sure dimension is OK
print(features.shape)
print(labels.shape)
Jika ukurannya sudah sesuai (hanya baris/elemen pertama saja) kita lanjut ke kode selanjutnya. Ada sedikit modifikasi pada label. Yakni, karena keras to_categorical mengkonversi dari 0, sedangkan label kita mulai dari 1 maka ukuran output label menjadi kelebihan satu kategori, dalam hal ini yang seharisnya 8 dimensi menjadi 9 dimensi. Kita hapus dimensi (kolom) pertama karena hanya berisi 0 (lagi, label kita mulai dari 1, sehingga hanya kolom k2 dua yg terisi).
# remove first column because label start from 1 (not from 0)
labels_oh = labels_oh[:,1:]

Terakhir, kita simpan fitur dan label yang sudah terekstrak dalam python .npy file. Masing-masing kita simpan sebagai X dan y (Biasanya, input disimpan dalam variabel X (capital) dan output target dalam y (kecil).
# If all is OK, let save it 
np.save('X', features)
np.save('y', labels_oh)
Selesai untuk ekstraksi fitur. Jika kita jalankan file save_feature.py, maka hasilnya seperti berikut, tidak ada error.



Klasifikasi dengan Deep Learning

File python kedua adalah ser_ravdess.py yang mengolah input X.npy dan y.npy hasil ekstraksi fitur dari file extract_feature.py. Berikut detailnya.

Modul/paket yang kita gunakan/import adalah sebagari berikut,
import numpy as np
from keras.models import Sequential  
from keras.layers import Dense, Activation  
from keras.layers import Dropout  
from sklearn.model_selection import train_test_split  
from sklearn.metrics import confusion_matrix  
import pandas as pd  
import seaborn as sns  
import matplotlib.pyplot as plt

Paket seaborn kita guanakan untuk membuat plot confusion matrix, scikit-learn kita gunakan untuk membagi dataset, selebihnya paket yang umum dan wajib kita gunakan (numpy, keras, matplotlib).
Selanjutnya, kita panggil data yang telah kita simpan dalam .npy:
# load feature data
X=np.load('X.npy')  
y=np.load('y.npy')  
train_x, test_x, train_y, test_y = train_test_split(X, y, test_size=0.33, random_state=42)

Arsitektur Deep neural network (DNN) atau deep learning yang akan kita buat adalah seperti gambar di bawah ini, terdiri dari 4 hidden layer, masing-masing berisi:193, 400, 200, dan 100 units/nodes. Kita simpan unit tersebut ke dalam beberapa variabel.

Arsitektur DNN yang akan dibuat. Layer pertama merupakan layer input. Total ada empat layer, layer terakhir terhubung ke layer output (tidak nampak pada gambar di atas).
# DNN layer units
n_dim = train_x.shape[1]  
n_classes = train_y.shape[1]  
n_hidden_units_1 = n_dim  
n_hidden_units_2 = 400 # approx n_dim * 2  
n_hidden_units_3 = 200 # half of layer 2  
n_hidden_units_4 = 100

Sedangkan deep learning-nya sendiri kita simpan dalam sebuah fungsi "create_model".
def create_model(activation_function='relu', init_type='normal', optimiser='adam', dropout_rate=0.2):  
    model = Sequential()  
    # layer 1  
    model.add(Dense(n_hidden_units_1, input_dim=n_dim, kernel_initializer=init_type, activation=activation_function))  
    # layer 2  
    model.add(Dense(n_hidden_units_2, kernel_initializer=init_type, activation=activation_function))  
    model.add(Dropout(dropout_rate))  
    # layer 3  
    model.add(Dense(n_hidden_units_3, kernel_initializer=init_type, activation=activation_function))  
    model.add(Dropout(dropout_rate))  
    #layer4  
    model.add(Dense(n_hidden_units_4, kernel_initializer=init_type, activation=activation_function))  
    model.add(Dropout(dropout_rate))  
    # output layer  
    model.add(Dense(n_classes, kernel_initializer=init_type, activation='softmax'))  
    # model compilation  
    model.compile(loss='categorical_crossentropy', optimizer=optimiser, metrics=['accuracy'])  
    return model
Semua baris dalam fungsi tersebut sangat mudah difahami, silahkan merefer ke sini untuk lebih detailnya.

Selanjutnya, kita panggil dan lihat arsitektur DNN yang telah dibuat.
# create the model  
model = create_model()  
print(model.summary())

Hasil (arsitekturnya) adalah sebagai berikut,
Model: "sequential_3"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_11 (Dense)             (None, 193)               37442     
_________________________________________________________________
dense_12 (Dense)             (None, 400)               77600     
_________________________________________________________________
dropout_7 (Dropout)          (None, 400)               0         
_________________________________________________________________
dense_13 (Dense)             (None, 200)               80200     
_________________________________________________________________
dropout_8 (Dropout)          (None, 200)               0         
_________________________________________________________________
dense_14 (Dense)             (None, 100)               20100     
_________________________________________________________________
dropout_9 (Dropout)          (None, 100)               0         
_________________________________________________________________
dense_15 (Dense)             (None, 8)                 808       
=================================================================
Total params: 216,150
Trainable params: 216,150
Non-trainable params: 0
_________________________________________________________________
None

Terlihat 4 dense layer dengan total trainable parameter sebanyak 216150. Layer dropout merupakan pengurang unit untuk menghindari overfitting.

Selanjutnya kita latih model dengan model.fit()
# train the model  
hist = model.fit(train_x, train_y, epochs=200, validation_data=[test_x, test_y], batch_size=4)
Untuk melihat akurasi maksimum yang dicapai ketika validasi/development,
print(max(hist.history['val_accuracy']))

Dalam hal ini saya memperoleh nilai seperti ini,
0.6449579831932774


Untuk melihat hasil akurasi ketika training, ganti 'val_acc' dengan 'acc'. Untuk menilai apakah arsitektur deeplearning yang kita buat sudah berjalan dengan baik sebenarnya bukan dengan melihat nilai akurasi/akurasi validasi akurasi, namun membandingkan nilai loss dengan val_loss. Jika kedua nilainya sama, berarti hasilnya sudah maksimal (sulit untuk ditingkatkan lagi).

Langkah terakhir adalah dengan melihat akurasi pada data test.
# evaluate model, test data may differ from validation data
evaluate = model.evaluate(test_x, test_y, batch_size=4)
print(evaluate) 
Variabel 'evaluate' menunjukkan nilai loss dan akurasi sebagai berikut.
[2.087342866086108, 0.6092436974789915]
Semakin kecil nilai loss semakin baik, berbanding terbalik dengan akurasi. Pada data di atas, nilai loss yang dicapai pada data test adalah 2.08 dengan akurasi sebesar 60.92%.

Hasil


Untuk mendapatkan interpretasi dari hasil training yang telah dilakukan oleh deep learning, kita bisa memplot loss dan akurasi yang diperoleh ketika proses tersebut (sebagai fungsi epoch). Berikut kode sederhananya untuk membuat dua plot untuk loss dan akurasi.
fig, axs = plt.subplots(nrows=1, ncols=2, constrained_layout=True)
ax = axs[0]
ax.plot(hist.history['loss'])
ax.set_ylabel('loss')
ax.set_xlabel('epochs')
ax = axs[1] 
ax.plot(hist.history['accuracy'])
ax.set_ylabel('accuracy')
ax.set_xlabel('epochs')
plt.show()
Hasilsnya adalah gambar berikut.


Untuk memonitor overfitting, langkah awal paling baik adalah dengan membandingkan loss dengan validation loss (begitu juga akurasi vs akurasi validasi). Kita modifikasi script untuk plotting di atas menjadi sebagai berikut,
fig, axs = plt.subplots(nrows=1, ncols=2, constrained_layout=True)
ax = axs[0]
ax.plot(hist.history['loss'], label='train')
ax.plot(hist.history['val_loss'], label='val')
ax.legend()
ax.set_ylabel('loss')
ax.set_xlabel('epochs')
ax = axs[1] 
ax.plot(hist.history['accuracy'], label='train')
ax.plot(hist.history['val_accuracy'], label='val')
ax.legend()
ax.set_ylabel('accuracy')
ax.set_xlabel('epochs')
plt.show()

Hasilnya adalah sebagai berikut.

Dari gambar di atas sebenarnya kita sudah mencapai loss validasi minimum pada epoch sekitar 75 kali. Namun, kalau kita perhatikan, akurasi tetap sedikit naik. Ini yang saya agak masih kurang faham. Dari teori deep learning, sistem akan mencari titik (loss) minimum. Dan pada saat itulah akurasinya maksimum. Mungkin karena sistem masih belajar walaupun sedikit, sehingga hasil (akurasinya) tetap dalam trend naik, walaupun sedikit.

Terakhir, untuk mengetahui hasil pengenalan per kategori (pada kategori emosi apa sistem kita bisa mengenali dengan baik, dan pada kategori apa yang kurang) kita bisa memplot confusion matrix (matrik kebingungan?). Untuk mendapatkan confusion matrix, kita harus membuat prediksi hasil pengenalan kategori emosi oleh sistem dan membandingkan hasilnya dengan nilai sebenarnya (true value). Kode berikut membuat prediksi kategori emosi dari data test (text_x), menyimpannya sebagai 'predict' dan membandingkan dengan nilai sebenarnya 'test_y'. Plot confusion matrix kita buat dengan bantuan scikit-learn dan pandas.
# predicting emotion of audio test data from the model  
predict = model.predict(test_x,batch_size=4)
emotions=['neutral', 'calm', 'happy', 'sad', 'angry', 'fearful', 'disgust', 'surprised']  

# predicted emotions from the test set  
y_pred = np.argmax(predict, 1)  
predicted_emo = []   
for i in range(0,test_y.shape[0]):  
    emo = emotions[y_pred[i]]  
    predicted_emo.append(emo)
    
actual_emo = []  
y_true = np.argmax(test_y, 1)  
for i in range(0,test_y.shape[0]):  
    emo = emotions[y_true[i]]  
    actual_emo.append(emo)
        
# generate the confusion matrix  
cm = confusion_matrix(actual_emo, predicted_emo)  
index = ['angry', 'calm', 'disgust', 'fearful', 'happy', 'neutral', 'sad', 'surprised']  
columns = ['angry', 'calm', 'disgust', 'fearful', 'happy', 'neutral', 'sad', 'surprised']  
cm_df = pd.DataFrame(cm, index, columns)                      
plt.figure(figsize=(10,6))  
sns.heatmap(cm_df, annot=True)

Hasilnya adalah sebagai berikut,

Angka pada tiap sel kolom di atas menunjukkan berapa data yang ditebak benar. Misal, data angry yang ditebak benar angry ada 46, sedangkan data calm yang ditebak angry tidak ada (alias 0). Dari plot confusion_matrix di atas kita tahu bahwa sistem kita handal pada kategori emosi surprise, calm dan angry, namun kurang akurat pada kategori neutral. Ini bisa menjadi input untuk perbaikan sistem kedepannya.

Plot model
Untuk memplot model dari Keras, gunakan kode berikut.
from keras.utils import plot_model
plot_model(model, to_file='model_lstm_ravdess.pdf', show_shapes=True) 

Pengaruh Jumlah Fitur pada Akurasi

Pada eksperimen kali ini, saya mencoba mengubah jumlah fitur yang dipakai sebagai input. Berikut perbandingan fitur dengan akurasinya (jumlah dalam kurung menunjukkan jumlah fitur):

  • mfcc, mfcc_delta, mfcc_delta2 (120) = 0.5
  • mfcc, mfcc_delta, mfcc_delta2, chroma, mel, contrast, tonnetz (273) = 0.56
  • mfcc, chroma, mel, contrast, tonnetz (193) = 0.6

Keseluruhan kode di atas saya host di repository github berikut,
https://github.com/bagustris/ravdess_ser

Halaman ini akan diupdate secara berkala dan direvisi, jika ada.


Referensi:

1. https://medium.com/@raihanh93/speech-emotion-recognition-using-deep-neural-network-part-i-68edb5921229
2. https://medium.com/@raihanh93/speech-emotion-recognition-using-deep-neural-network-part-ii-4189273f3e2a
3. Livingstone, Steven R., & Russo, Frank A. (2018). The Ryerson Audio-Visual Database of Emotional Speech and Song (RAVDESS) (Version 1.0.0) [Data set]. PLoS ONE. Zenodo. http://doi.org/10.5281/zenodo.1188976
4.https://ieeexplore.ieee.org/document/1035731?arnumber=1035731
5.https://dl.acm.org/citation.cfm?id=1178727
Related Posts Plugin for WordPress, Blogger...