פיתוח מודל לאנליזת סנטימנט באמצעות למידת מכונה ו-keras

מחבר:
בתאריך:

במדריך זה נלמד את המחשב להבחין בין טקסטים המבטאים דעות חיוביות ושליליות (sentiment analysis) באמצעות python ו-Keras. למידת מכונה ממידע טקסטואלי נקראת "עיבוד שפה טבעית" (Natural Language Processing, NLP).

מסד הנתונים שבו נשתמש מקורו ב-IMDB, האתר המקיף באינטרנט המוקדש לקולנוע, שבו, בין השאר, גולשים מן השורה מחווים את דעתם על סרטים. מכיוון שיש בו כל כך הרבה ביקורות גולשים, חלקם חיוביות וחלקם שליליות, ניתן להשתמש במאגר הנתונים כדי לאמן מודל משלנו שידע להבחין בין טקסטים חיוביים ושליליים sentiment analysis. היתרון של מכונה לאנליזה של סנטימנט שהיא יכולה לעשות בזמן קצר אנליזה של כמות אדירה של מידע טקסטואלי והחיסרון הוא שהמודלים שאנחנו מפתחים לא באמת מבינים שפה.

אימון המודל יצריך מאיתנו עיבוד של הטקסט לוקטורים מספריים כי Keras לא יודע לעבוד ישירות עם טקסטים. הפיכת המידע הטקסטואלי לוקטורים יכלול שני שלבים. ראשית, טוקניזציה, אשר מחליפה את המילים באינדקסים ואת הטקסטים בוקטורים מספריים. שנית, embedding, אשר דוחס את הטוקנים לוקטורים שבהם ניתן משקל גם למשמעות הסמנטית.

הרשת הנוירונית שבה נשתמש לאימון המודל תכלול מלבד שכבת embedding גם שכבות של יחידות חוזרות recurrent units מכיוון שסדר המילים הוא חשוב, וכאשר הסדר חשוב משתמשים ב-recurrent units.

להורדת קוד הפרויקט שאותו נפתח במדריך

 

ייבוא הספריות

כרגיל, numpy ו-pandas בשביל לעבוד עם מערכים מתוחכמים ו-Keras בשביל לעשות למידת מכונה.

import numpy as np
import pandas as pd

import tensorflow as tf

from tensorflow.python.keras.models import Sequential
from tensorflow.python.keras.layers import Dense, GRU, Flatten, Embedding
from tensorflow.python.keras.optimizers import Adam
from tensorflow.python.keras.preprocessing.text import Tokenizer
from tensorflow.python.keras.preprocessing.sequence import pad_sequences

 

מסד הנתונים

את גליון האקסל שהורדתי מ- Kaggle (מסד הנתונים להלן) טענתי לסביבת Colab https://www.kaggle.com/utathya/imdb-review-dataset

# Upload the csv file
from google.colab import files
files.upload()

פתחתי תיקייה database שאליה העברתי את מסד הנתונים.

# make a directory to accommodate the dataset
!mkdir -p database
!unzip imdb-review-dataset.zip -d database

קריאת מסד הנתונים והעבודה איתו בפייתון נעשית באמצעות ייבוא ל-pandas.

df = pd.read_csv('./database/imdb_master.csv',encoding='latin-1')

נסקור את מסד הנתונים head, tail, sample:

df.head()

נשאר רק עם העמודות הרלוונטיות.

# get rid of the unnecessary columns
df1 = df[['type','review','label']]
df1

כמה דוגמאות יש לנו?

df1.shape # (100000,3)

10,000 דוגמאות.

אילו קטגוריות יש לנו בסט הנתונים?

# which categories do we have?
df1.label.unique()
array(['neg', 'pos', 'unsup'], dtype=object)

כמה דוגמאות יש לנו מכל קטגוריה?

# the frequency of each category
df1.label.value_counts()
unsup 50000
pos 25000
neg 25000
Name: label, dtype: int64

הקטגוריה unsup יכולה לשמש לאחר הלמידה במידה ונרצה לבחון את טיב המודל אבל מכיוון שהיא מצריכה מאיתנו לעבוד בסיווג הדוגמאות לא נשתמש בה.

# drop the 'unsup' samples
filtered_data = df1[df1.label != 'unsup']
# frequency of each category in labels
filtered_data.label.value_counts()
neg 25000
pos 25000
Name: label, dtype: int64

אני מרגיש נוח יותר לעבוד עם תגיות מספריות מאשר עם קטגוריות שמיות, ולשם כך נוסיף עמודת סנטימנט שבה הביקורות השליליות יקבלו 0 והחיוביות 1.

filtered_data['sentiment'] = np.where((filtered_data['label']=='pos'),1,0)
filtered_data.sample(5)
  type review label sentiment
33877 train Too Much of Something Borrowed Grade B-<br /><... neg 0
30935 train I like Fulci films, i really do and not in som... neg 0
41189 train Martin Sheen, Michelle Phillips, Stuart Margol... pos 1
41825 train I really can't say too much more about the plo... pos 1
30588 train For anyone that loves predictable movies with ... neg 0

מעניין לקרוא את אחת הביקורות.

# watch a single review
filtered_data.loc[0]['review']
"Once again Mr. Costner has dragged out a movie for far longer than necessary. Aside from the terrific sea rescue sequences, of which there are very few I just did not care about any of the characters. Most of us have ghosts in the closet, and Costner's character are realized early on, and then forgotten until much later, by which time I did not care. The character we should really care about is a very cocky, overconfident Ashton Kutcher. The problem is he comes off as kid who thinks he's better than anyone else around him and shows no signs of a cluttered closet. His only obstacle appears to be winning over Costner. Finally when we are well past the half way point of this stinker, Costner tells us all about Kutcher's ghosts. We are told why Kutcher is driven to be the best with no prior inkling or foreshadowing. No magic here, it was all I could do to keep from turning it off an hour in."

יותר מנומק מחלק לא קטן מהפירסומים בעיתונות הישראלית. בפרט כתבות מהזן שמסביר מדוע מחירי הדיור יעלו לנצח.

 

טוקניזציה

מחשבים צריכים מספרים כדי לעשות למידת מכונה אז במקום לאמן את המודל ישירות על טקסטים ממסד הנתונים נמיר את המילים האינדיבידואליות מהם מורכבים הטקסטים לטוקנים (tokens) שבהם לכל מילה ייחודית בטקסט יוקצה אינדקס מספרי.

במקום לעבוד עם כל המילים במסד הנתונים, נעבוד רק עם 10,000 המילים הנפוצות ביותר שמהם Keras ייצר 10,000 טוקנים.

# Here we limit the number of tokens to the 10,000
# most used words in the dataset
num_words = 10000
tokenizer = Tokenizer(num_words=num_words)

נסקור את מילון הטוקנים שזה עתה יצרנו.

# explore the dictionary of tokens
dictionary = tokenizer.word_index

dictionary
{'the': 1,
 'and': 2,
 'a': 3,
 'of': 4,
 'to': 5,
 'is': 6,
 'br': 7,
 'in': 8,
 'it': 9,
 'i': 10,
 'this': 11,
 'that': 12,
 'was': 13,
 'as': 14,
 'for': 15,
 'with': 16,
 'movie': 17,
 'but': 18,
 'film': 19,
 'on': 20,
 'not': 21,
 'you': 22,
 'are': 23,
 'his': 24,
 'have': 25,
 'be': 26,
 'one': 27,
 'he': 28,
 'all': 29,
 'at': 30,
 'by': 31,
 'an': 32,
 'they': 33,
 'so': 34,
 'who': 35,
 'from': 36,
 'like': 37,
 'or': 38,
 'just': 39,
 'her': 40,
…}

המילה the היא הנפוצה ביותר במסד הנתונים ולכן קיבלה את האינדקס 1, ובמקום ה-17 המילה film.

נפריד את ה-dataset ל-train ול-test.

# separate the train data
train_set = filtered_data[filtered_data.type == 'train']
# separate the test data
test_set = filtered_data[filtered_data.type == 'test']

נעשה טוקניזציה של סט הנתונים train_set.

# tokenize - use the tokenizer to convert all texts in the training set to list of tokens

x_train_tokens = tokenizer.texts_to_sequences(train_set['review'])

נציץ בדוגמה אחת של טקסט שעבר טוקניזציה.

# a single review after tokenization
np.array(x_train_tokens[0])
array([ 64, 4, 3, 128, 35, 45, 7159, 1395, 15, 3, 4968,
 537, 41, 16, 3, 615, 129, 12, 6, 3, 1322, 472,
 4, 1877, 202, 3, 6407, 309, 6, 661, 82, 32, 2018,
 1125, 2750, 31, 1, 947, 4, 44, 5452, 480, 9, 2842,
 1877, 1, 223, 55, 16, 54, 825, 1353, 853, 231, 9,
 39, 96, 122, 1499, 57, 143, 36, 1, 1006, 142, 26,
 661, 122, 1, 411, 58, 94, 2231, 308, 765, 5, 3,
 873, 20, 3, 1891, 652, 44, 126, 71, 22, 233, 101,
 16, 47, 49, 633, 31, 731, 78, 731, 406, 3207, 2,
 8497, 67, 26, 107, 3189])

זה יהיה נחמד אם נוכל להמיר את הטוקנים חזרה לטקסט. הפונקציה הבאה תעזור לנו בזה:

# Nice, but what is in the text.
# The following function converts the tokens back to the original text.
def tokens_to_text(dict, tokens):
  words = []
  for token in tokens:

words.append(list(dict.keys())[list(dict.values()).index(token)]) return ' '.join(words)

נבדוק את הפונקציה:

# Let's test it
tokens_to_text(dictionary, np.array(x_test_tokens[0]))
"once again mr costner has dragged out a movie for far longer than necessary aside from the terrific sea rescue sequences of which there are very few i just did not care about any of the characters most of us have ghosts in the closet and character are realized early on and then forgotten until much later by which time i did not care the character we should really care about is a very cocky the problem is he comes off as kid who thinks he's better than anyone else around him and shows no signs of a closet his only appears to be winning over costner finally when we are well past the half way point of this stinker costner tells us all about ghosts we are told why is driven to be the best with no prior or no magic here it was all i could do to keep from turning it off an hour in"

כפי שאפשר לראות הטוקניזציה גם ניקתה את הטקסט מסימני הפיסוק והפכה את כל האותיות לקטנות.

טוקניזציה של סט הנתונים test_set.

# tokenize the test set
x_test_tokens = tokenizer.texts_to_sequences(test_set['review'])

 

ריפוד וקיצוץ

יש לנו עכשיו וקטורים של אינדקסים מספריים באורכים שונים אולם Keras צריך שכל הוקטורים שמוזנים באותו ה-batch יהיו באורך זהה. כדי לפתור את הבעיה נחליט על אורך אחיד לכל הוקטורים ולאחר מכן נקצץ את הוקטורים הארוכים ונרפד באפסים את הוקטורים הקצרים.

# Determine the maximum length in words
# so the it includes all the texts that are in
# the 95% range and so we can later exclude the
# parts of the texts that are longer
import math

lengths = [len(item) for item in x_train_tokens + x_test_tokens]
arr = np.array(lengths)
mean = np.mean(arr, axis=0)
stdev = np.std(arr, axis=0)
max_len = math.floor(mean + 2 * stdev)
max_len # 544

אורך הוקטור 544 ע"פ ממוצע ו-2 סטיות תקן של אורכי כל הוקטורים. מה שיספיק כדי לכלול את האורך המלא של 95% מהוקטורים.

את הריפוד והקיצוץ של הווקטורים לאורך הרצוי יבצע למעננו Keras:

# pad the sequences with the 'pre' parameter so the padding zeroes will be at the beginning
# because the last part of each sequence convey extra meaning for the recurrent units of the GRU
padded_train_tokens = pad_sequences(x_train_tokens, maxlen=max_len, padding='pre', truncating='pre')

הפרמטר pre גורם להוספת האפסים המרפדים בתחילת הוקטור ולא בסופו כי המשקל של היחידות האחרונות הוא גבוה יותר כשעובדים עם recurrent units בשל אפקט הגרדיאנטים הנעלמים שמשכיח את האפקט של תחילת הוקטור עד שמגיעים לסופו.

אחרי ריפוד וקיצוץ סט האימון נעשה את אותו הדבר לסט המבחן.

padded_test_tokens = pad_sequences(x_test_tokens, maxlen=max_len, padding='pre', truncating='pre')

 

Embedding בניית ואימון הרשת הנוירונית

טוקניזציה לבדה אינה מספיקה לצורך ביצוע למידת מכונה בגלל שהתהליך מייצר מילון ארוך של מילים (10,000 במקרה שלנו) ועיבוד כל כך הרבה מידע הוא מדי יקר עבור מחשב. בנוסף, המילים איבדו את משמעותם הסמנטית בתהליך. כדי לפתור בעיות אלה אנו משתמשים בשכבת Embedding בתור השכבה הראשונה של הרשת העצבית.

לאחר שכבת ה-Embedding אנו משתמשים בשכבות Recurrent Neural Network בארכיטקטורת GRU. אנו משתמשים ב-RNN מאחר וסדר המילים חשוב וכאשר אנו מנסים לפתח מודל עבור נתונים שבהם יש משמעות לסדר אנו משתמשים בRNN - זה יכול להיות בארכיטקטורת GRU או LSTM. אני מעדיף את GRU על LSTM כי המבנה של היחידה החוזרת הוא פשוט ואלגנטי.

כדי להבין מדוע הסדר חשוב אפשר לחשוב על הטקסט "אהבה" אשר שונה במהותו מהטקסט "ללא אהבה", כאשר משמעותו של הטקסט השני מתחוורת רק כאשר מצרפים את המילים "ללא" ו-"אהבה" בסדר דיקדוקי נכון.

# define the model
# 128 output 
num_classes = 2
model = Sequential()

# the number of words in the number of inputs and the number of outputs is 128
# because that's what I found in the Keras documentation
model.add(Embedding(num_words, 64, input_length=max_len))

# 3 GRU layers
# with fewer nodes in the consecutive layers
# to allow the later layers to see less details and more of the whole picture
# and so to be able to generalize - the whole mark a meaningful learning process
# the first 2 GRU layers need to return a sequence of outputs
# to feed the next GRU layer
model.add(GRU(units=64, return_sequences=True))
model.add(GRU(units=32, return_sequences=True))

# the 3rd GRU layer need to feed its output to the dense layer
# so we don't return a sequence
model.add(GRU(units=4, return_sequences=False))

# dense layer calculates a value between 0 and 1 as the output
model.add(Dense(1, activation='sigmoid'))

אני משתמש פה ב-3 שכבות RNN שבכל אחת יש פחות יחידות מאשר בקודמת כדי לאפשר לשכבות המתקדמות לראות פחות פרטים ויותר מהתמונה הגדולה וכך להכליל - שזו המהות של תהליך למידה משמעותי.

השכבה האחרונה ברשת היא שכבת dense אשר מסווגת את הטקסטים לאחת משתי קטגוריות - 0 או 1 - סנטימנט שלילי או חיובי.

נקמפל את המודל עם פרמטרים שמתאימים לסיווג לקטגוריות.

# compile the model
# 2 classes hence the loss function is 'binary_crossentropy'
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['acc'])

נציג את סיכום המודל:

# summarize the model
print(model.summary())

נאמן את המודל:

# fit the model
model.fit(padded_train_tokens, np.array(train_set['sentiment']), epochs=3, verbose=1)
Epoch 1/3
25000/25000 [==============================] - 1683s 67ms/sample - loss: 0.5514 - acc: 0.6907
Epoch 2/3
25000/25000 [==============================] - 1641s 66ms/sample - loss: 0.4544 - acc: 0.7734
Epoch 3/3
25000/25000 [==============================] - 1652s 66ms/sample - loss: 0.2919 - acc: 0.8831

 

הערכת המודל

אחרי שפיתחנו ואימנו את המודל חשוב לדעת עד כמה הוא אפקטיבי. המדדים loss ו-accuracy יסייעו לנו להעריך את המודל.

המודל יכול להיות טוב רק אם הוא מצליח להכליל ממסד הנתונים שהוא למד ממנו לנתונים אחרים שהוא לא נחשף אליהם בתהליך האימון. לפיכך, את ההערכה נעשה על סט המבחן.

loss, accuracy = model.evaluate(padded_test_tokens, np.array(test_set['sentiment']), verbose=1)
print('Accuracy: %0.2f' % (accuracy*100))
print('Loss: %0.2f' % loss)
25000/25000 [==============================] - 636s 25ms/sample - loss: 0.3056 - acc: 0.8728
Accuracy: 87.28
Loss: 0.31

87% דיוק אחרי 3 מחזורים בלבד. יפה מאוד!

מעניין לבדוק האם המודל שוגה יותר בסיווג ביקורות חיוביות או שליליות לשם כך נשתמש ב-confusion matrix.

אילו דוגמאות סווגו באופן שגוי?

# evaluate with a confusion matrix
# see the samples that were misclassified
predictions = model.predict(padded_test_tokens)

predictions
array([[0.08226028],
  [0.05003896],
  [0.03048536],
  ...,
  [0.5752455 ],

[0.9702544 ], [0.9014092 ]], dtype=float32)

אנחנו רוצים להשוות את הערכים החזויים לערכים בפועל אבל הערכים של התחזיות נעים על כל הרצף שבין 0 ל-1 בעוד הקטגוריות שאליהם אנחנו צריכים להשוות הם דיסקריטיות, 0 או 1. אז בואו נחליט שתחזיות שקיבלו ניקוד גבוה מ-0.5 יוגדרו כקטגוריה 1 אחרת יוגדרו 0.

# predicted class indices
y_pred = np.where((predictions>0.5),1,0)

y_pred
array([[0],
       [0],
       [0],
       ...,
       [1],
       [1],
       [1]])

נחשב את ה-confusion מטריקס:

from sklearn.metrics import classification_report, confusion_matrix

# by the Confusion Matrix and Classification Report of sklearn
print('Confusion Matrix')
print(confusion_matrix(test_set['sentiment'], y_pred))
print('Classification Report')
target_names = ['Negative', 'Positive']
print(classification_report(test_set['sentiment'], y_pred, target_names=target_names))

נראה שהנטייה של המודל שלנו היא לשגות ולסווג ביקורת שליליות כחיובית.

נצפה בדוגמה אחת שמדגימה סיווג שגוי.

# see the misclassified samples
incorrect = (y_pred.reshape(25000,) != test_set['sentiment'])
incorrect_texts = test_set['review'][incorrect]
Incorrect_texts
array([[0.08226028],
       [0.05003896],
       [0.03048536],
       ...,
       [0.5752455 ],
       [0.9702544 ],
       [0.9014092 ]], dtype=float32)

נשלוף את אחת הדוגמאות:

incorrect_texts[339]
'The first season was great - good mix of the job and the brother and friends at home. it was actually a pretty funny show.<br /><br />Now it shows up again and the brother and the two hot chicks are gone -- and the whole thing revolves around the airline company. Even the old man who runs the company has gone downhill - way too over the top, where before it was perfect.<br /><br />That and no more Sarah Mason - one of the best looking girls in Hollywood.<br /><br />This is what happens when you let some execs get their hands on a show. You can almost see the meeting "the old man is funny, lets focus on him, make him way over the top and make it all about the airline.. it\'ll be a nutty version of the office!" Anyhow, no hot chicks, no watch.'
test_set.loc[339]
type  test
review    The first season was great - good mix of the j...
label     neg
sentiment 0
Name: 339, dtype: object

אמנם הביקורת מסווגת כשלילית במסד הנתונים IMDB אולם המודל שפיתחנו מסווג את הביקורת דווקא כחיובית כנראה בגלל האחוז הגבוה של מילים חיוביות.

עוד תרגיל מעניין הוא לנסות לבחון את הרשת על טקסטים שאני כתבתי:

reviews1 =[
   'Bad movie, worst actors, and really bad director. this assembly of misfits is better off the film industry for good',
   'Hillarious, brilliant, kudos, two thumbs up. one of the greatest movies you can see this year',
   'god is a dj and the masses are his loyal servants',
   'I dont want to waste your time but this disaster movie is probably one of the worst movies to ever miss despite the lack of quality and the horrible production. who knows it might even become this generations rockies horror show so you actually want to watch it'
]
tokenized = tokenizer.texts_to_sequences(reviews1)
padded = pad_sequences(tokenized, maxlen=max_len, padding='pre', truncating='pre')
model.predict(padded)

והרי התחזית:

array([[0.04942188],
 [0.96283805],
 [0.6999015 ],
 [0.04030073]], dtype=float32)

טקסט 1 השלילי מאוד וגם טקסט 2 החיובי מאוד סווגו נכונה על ידי המערכת. טקסט 3 בעל האופי הניטרלי אכן קיבל ציון בינוני. טקסט 4 שמשתמש בהרבה מילים "שליליות" כדי להמריץ אנשים לצפות בסרט פולחן מצליח להטעות את המודל שמסווג אותו כשלילי.

 

סיכום

במדריך זה ראינו כיצד לאמן מודל לעיבוד שפה באמצעות רשת נוירונית בממשק Keras. ראינו שהמודל אותו פיתחנו הצליח בזיהוי 87% מהדוגמאות מסט המבחן אחרי שלושה מחזורי אימון בלבד. גם ראינו כיצד המודל שלנו שוגה בהבנת כוונות נסתרות וקריאה בין השורות.

ייתכן שאימון יותר מסיבי של הרשת הכולל הגברת מספר ה-epochs משלושה למספר גדול יותר ושינוי בארכיטקטורת הרשת היה פותר לפחות חלק מהבעיות. אם הייתי צריך לעשות אופטימיזציה של הרשת הייתי מנסה כמה ארכיטקטורות וגם הייתי משנה את ההיפר-פרמטרים, אחד בכל פעם. לדוגמה, האם אימון המודל במשך 3,6,12 או-15 epochs תורם לשיפור דיוק הסיווג. בדומה למה שעשיתי במדריך בחירת המודל המשמש ללמידת מכונה.

האפשרות של אימון מודל משלנו עם סט נתונים גדול ככל שיהיה צריכה לקבל עדיפות נמוכה יותר מאשר שימוש במודלים קיימים, שאומנו על ידי מומחים בתחום, כדוגמת word2vec שאת יכולותיו הדגמתי במדריך קודם ומודלים מתקדמים יותר כדוגמת BERT שאותו ננסה בעתיד. עם זאת, בשפות אקזוטיות כדוגמת עברית לעתים לא יהיה מנוס ונאלץ לאמן את המודל בעצמנו ואז טוב שיש Keras שיכול לעזור לנו במשימה.

לכל המדריכים בסדרה על למידת מכונה

 

אהבתם? לא אהבתם? דרגו!

0 הצבעות, ממוצע 0 מתוך 5 כוכבים