איתור SMS ספאמי באמצעות טכנולוגית Transformer

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

מודלים מבוססי ארכיטקטורת טרנספורמר Transformer שינו מן היסוד את הדרך בה אנו עובדים עם מודלים של עיבוד שפה אנושית NLP בזכות מנגנון self attention המאפשר עבודה עם רצפים ארוכים של מידע. אחת הבעיות היא שצריך כמות אדירה של נתונים ומשאבי מחשוב כדי לאמן מודלים מסוג זה. לדוגמה, מודל GPT-3 אומן על קורפוס של טריליון מילים הכולל את ויקיפדיה, ספריות שלמות של ספרים ונתונים שמקורם בגלישה באינטרנט (שווה ערך לעשרת אלפים שנות דיבור ללא הפסקה בקצב של 100 מילים בדקה). לכן במקום לאמן מודל בכל פעם מחדש נעדיף להשתמש במודלים מאומנים pretrained models. מאז הופעת הארכיטקטורה ב-2017 נוצרו אלפי מודלים שחלק גדול מהם זמין להורדה ולשימוש. כאשר מיזם hugging face מספק ממשק אחיד לשימוש במודלים. במדריך זה נשתמש במודל קלאסי לעיבוד שפה אנושית ששמו BERT כדי להבחין בין הודעות SMS תקינות וספאמיות (ham or spam). המדריך מהווה המשך למדריכים קודמים בסדרה על למידת מכונה בהם סיווגנו הודעות SMS בגישות שונות: זיהוי SMS ספאמי באמצעות bag of words, זיהוי SMS ספאמי באמצעות RNN, זיהוי SMS ספאמי באמצעות RNN ומרחב הטמעה שאומן מראש.

שימוש בטכנולוגית טרנספורמר לזיהוי SMS ספאמי

 

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

 

נייבא את הספריות הדרושות ללמידת מכונה:

import numpy as np
import pandas as pd
 
import tensorflow as tf
from tensorflow import keras
  • Numpy פונקציות מתמטיות ועבודה עם מערכים.
  • Pandas לסידור ולסינון המידע בדומה לגיליון אקסל.
  • ספריית TensorFlow בשביל למידת מכונה. נעבוד עם הספרייה באמצעות ממשק Keras.

נתקין את ספריית Transformers של Hugging Face:

!pip install transformers -q
!pip install datasets -q
  • ספריית Transformers של Hugging Face היא ספרייה פופולרית של פייתון הכוללת אלפי מודלים לעבודה עם שפות אנוש NLP
  • ספריית Datasets של Hugging Face מאפשרת לעבוד עם מסדי נתונים.

 

מסד הנתונים

את מסד הנתונים הורדתי כקובץ CSV מאתר התחרויות Kaggle https://www.kaggle.com/datasets/uciml/sms-spam-collection-dataset

# import dataset
df = pd.read_csv('spam.csv', encoding = "ISO-8859-1", usecols=["v1", "v2"])
df.columns = ["Category", "Message"]

מה במסד הנתונים?

ham or spam SMS dataset from Kaggle

הקטגוריות הם שמיות (ham, spam). בשביל למידת מכונה נהפוך אותם למספריות:

# encode categories as numeric values
df['cat_int'] = pd.factorize(df['Category'])[0]
  • את תוצר הפקטוריזציה הזנו לתוך עמודה חדשה 'cat_int' הערך של הקטגוריה 'ham' הוא 0, ו-'spam' 1.

נסיר תווים שאינם סטנדרטיים מעמודת "Message":

# remove non ascii
df["Message"] = df["Message"].str.encode('ascii', 'ignore').str.decode('ascii')

נחלק את מסד הנתונים ל-3 קבוצות. אימון, הערכה ומבחן (train, validation, test):

from sklearn.model_selection import train_test_split
 
X = df["Message"]
y = df["cat_int"]
 
X_train, X_valid, y_train, y_valid = train_test_split(X, y,
   test_size=0.3,
   random_state=42)
 
X_valid, X_test, y_valid, y_test = train_test_split(X_valid, y_valid,
   test_size=0.33,
   random_state=42)

Hugging Face דורשים מידע במתכונת של Dataset Dictionary. לפני שנשנה את פורמט המידע ניצור 3 סטים של נתונים datasets:

train_dataset = pd.DataFrame(X_train).join(y_train)
valid_dataset = pd.DataFrame(X_valid).join(y_valid)
test_dataset = pd.DataFrame(X_test).join(y_test)

ניצור את ה-Dataset Dictionary משלוש הסטים:

# make a Dataset Dictionaries
 
train_ds = Dataset.from_pandas(train_dataset)
valid_ds = Dataset.from_pandas(valid_dataset)
test_ds = Dataset.from_pandas(test_dataset)
 
dataset = DatasetDict()
 
dataset['train'] = train_ds
dataset['valid'] = valid_ds
dataset['test'] = test_ds

מה ב-dataset:

dataset
DatasetDict({
    train: Dataset({
        features: ['Message', 'cat_int', '__index_level_0__'],
        num_rows: 3900
    })
    valid: Dataset({
        features: ['Message', 'cat_int', '__index_level_0__'],
        num_rows: 1120
    })
    test: Dataset({
        features: ['Message', 'cat_int', '__index_level_0__'],
        num_rows: 552
    })
})

 

Bert מודל שאומן מראש מהמאגר של hugging face

כל המודלים של Hugging face מרוכזים בדף המודלים בכתובת https://huggingface.co/models. כאשר ניתן לבחור מודל על פי המשימה בה אנו מעוניינים מתפריט Tasks. בלחיצה על המשימה Text Classification קיבלתי רשימה של 8650 מודלים אפשריים. אח"כ הוספתי סינון לפי Libraries כי רציתי מודלים שיכולים לעבוד עם TensorFlow. במקום הראשון היה מודל סיני. בשני, המודל הנבחר bert-base-uncased עם למעלה מ-22 מיליון הורדות. נגדיר את מזהה המודל שאיתו אנחנו רוצים לעבוד כדי שאח"כ נוכל להוריד את המודל ולעבוד עם ה- tokenizer המתאים.

MODEL_NAME = "bert-base-uncased"

 

טוקניזציה tokenization

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

נמיר את הטקסט לטוקנים:

from transformers import AutoTokenizer
 
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

נבחן כמה תכונות של ה-tokenizer:

  • כמה טוקנים באוצר המילים של המודל?

    print(f"Vocab size is : {tokenizer.vocab_size}")
    Vocab size is : 30522
  • מה אורך רצף הטוקנים המקסימלי בו ניתן להזין את המודל?

    print(f"Model max length is : {tokenizer.model_max_length}")
    Model max length is : 512

    אם הרצף שלנו ארוך מ-512 טוקנים אז צריך לקטוע אותו.

  • מה שם השדות בהם נזין את המודל?

    print(f"Model input names are: {tokenizer.model_input_names}")
    Model input names are: ['input_ids', 'token_type_ids', 'attention_mask']

    גם אם לא ברור כרגע. מיד נטפל גם בזה.

תהליך הטוקניזציה כולל:

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

נעשה טוקניזציה לרצף לדוגמה:

text = "correlation doesn't imply causation"
output = tokenizer(text)

מה קיבלנו?

print(f"Tokenized output: {output}")
Tokenized output: 
{'input_ids': [101, 16902, 2987, 1005, 1056, 19515, 6187, 10383, 3508, 102], 
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
  • 'input_ids' - הוא רצף האינדקסים המייצג את המחרוזת אותה הזנו
  • 'token_type_ids' - אינו רלבנטי למשימת הסיווג בה אנו עוסקים
  • 'attention_mask' - מסמן את הטוקנים שהמודל צריך לשים אליהם לב. במקרה זה, אין טוקנים שהמודל נדרש להתעלם מהם, לכן הרצף כולו מורכב מאחדות. אם היו טוקנים אחרים, לדוגמה כאלה המשמשים לריפוד, אז לפחות חלק מהרצף היה אפסים.

המערך 'input_ids' מתחיל בטוקן שמספר האינדקס שלו 101 המכונה [CLS] הוא טוקן מיוחד שמשמעותו תחילת הרצף. הרצף מסתיים בטוקן שמספר האינדקס שלו 102 המסמן את סיום הרצף וידוע בכינוי [SEP].

טוקנים מיוחדים נוספים הם:

print(tokenizer.special_tokens_map_extended)
{'unk_token': '[UNK]', 
'sep_token': '[SEP]', 
'pad_token': '[PAD]', 
'cls_token': '[CLS]', 
'mask_token': '[MASK]'}
  • הטוקן [UNK] משמש לקידוד כל אותם טוקנים הנגזרים מן הטקסט אך אינם קיימים באוצר המילים בהתאם לכך הוא unknown.
  • הטוקן [MASK] אינו רלבנטי למשימה שלנו. משמש כאשר מאמנים את המודל על ידי הסתרת אחוז קטן (10% - 15%) מהמילים בטקסטים כאשר המודל נדרש להשלים את החסר בעצמו.

ראינו את האינדקסים המחליפים את הטוקנים. לאילו טוקנים חילק המודל את הטקסט?

print(f"Tokenized tokens: {tokens}")
Tokenized tokens: ['[CLS]', 'correlation', 'doesn', "'", 't', 'imply', 'ca', '##usa', '##tion', '[SEP]']
  • לפעמים הטוקנים הם מילים ולפעמים חלקי מילים. לדוגמה, את המילה "causation" שנראה שאינה מספיק נפוצה כדי להיכלל באוצר המילים של Bert הטוקנייזר חילק לשלוש:

    'ca', '##usa', '##tion'

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

מעניין מה יקרה אם ננסה להזין טקסט בעברית הכולל אמוג'י?

text = "קורלציה בין שני גורמים אין משמעותה שאחד גורם לשני "
output = tokenizer(text)
tokens = tokenizer.convert_ids_to_tokens(output['input_ids'])
print(f"Tokenized tokens: {tokens}")
print(f"Tokenized text: {tokenizer.convert_tokens_to_string(tokens)}")

מעניינת החלוקה לטוקנים:

print(f"Tokenized text: {tokenizer.convert_tokens_to_string(tokens)}")
Tokenized tokens: ['[CLS]', 'ק', '##ו', '##ר', '##ל', '##צ', '##י', '##ה', 'ב', '##י', '##ן', 'ש', '##נ', '##י', 'ג', '##ו', '##ר', '##מ', '##י', '##ם', 'א', '##י', '##ן', 'מ', '##ש', '##מ', '##ע', '##ו', '##ת', '##ה', 'ש', '##א', '##ח', '##ד', 'ג', '##ו', '##ר', '##ם', 'ל', '##ש', '##נ', '##י', '[UNK]', '[SEP]']
  • תהליך הטוקניזציה חילק את הטקסט העברי לאותיות כי נראה שהמודל המאומן לא הכיל מספיק עברית כדי לייחס משמעות למילים שלמות.
  • ישנם שני סוגים של טוקנים. כאלה הפותחים בסולמית כפולה (##) וכל השאר. הסולמית באה לציין חלק מילה שאינו מופיע בתחילתה.
  • הטוקן [UNK] מציין את האימוג'י שלא מופיע באוצר המילים של המודל.

נרכיב חזרה את הטקסט מהטוקנים:

print(f"Tokenized text: {tokenizer.convert_tokens_to_string(tokens)}")
Tokenized text: [CLS] קורלציה בין שני גורמים אין משמעותה שאחד גורם לשני [UNK] [SEP]

 

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

# tokenize inside a function
MAX_SEQ_LEN = 100
def preprocess_function(examples):
   return tokenizer(examples["Message"], max_length=MAX_SEQ_LEN, truncation=True, padding='max_length')
  • מלבד המרה של המילים ברצפים לטוקנים הפונקציה מקנה אורך אחיד לכל הרצפים שהיא מייצרת. אם אורך הרצף עולה על 100 טוקנים היא מקצצת את הרצף, ואם הרצף קצר מ-100 טוקנים היא מוסיפה טוקנים של ריפוד padding tokens.

המתודה dataset.map() מפעילה את הפונקציה preprocess_function() על מסד הנתונים:

tokenized_dataset = dataset.map(preprocess_function, batched=True)

הפונקציה DataCollatorWithPadding() מייצרת אצוות batches אותם נוכל להזין למודל:

from transformers import DataCollatorWithPadding
 
data_collator = DataCollatorWithPadding(tokenizer=tokenizer, return_tensors="tf")
  • אם היינו מזינים לתוכה רצפים בעלי אורך שונה הפונקציה היתה גורמת לכל הרצפים להיות שווי אורך על ידי ריפוד הרצפים הקצרים עד לאורך של הרצף הארוך ביותר.

כדי שנוכל לכוונן fine-tune את המודל של hugging face באמצעות ספריית TensorFlow נמיר את מסדי הנתונים לפורמט tf.data.Dataset באמצעות המתודה to_tf_dataset():

BATCH_SIZE = 16
 
tf_train_dataset = tokenized_dataset["train"].to_tf_dataset(
   columns=["attention_mask", "input_ids"],
   label_cols=["cat_int"],
   shuffle=True,
   batch_size=BATCH_SIZE,
   collate_fn=data_collator,
)
 
tf_validation_dataset = tokenized_dataset["valid"].to_tf_dataset(
   columns=["attention_mask", "input_ids"],
   label_cols=["cat_int"],
   shuffle=True,
   batch_size=BATCH_SIZE,
   collate_fn=data_collator,
)
 
tf_test_dataset = tokenized_dataset["test"].to_tf_dataset(
   columns=["attention_mask", "input_ids"],
   label_cols=["cat_int"],
   shuffle=False,
   batch_size=BATCH_SIZE,
   collate_fn=data_collator,
)
  • על הדרך חילקנו לאצוות batches והפעלנו את האובייקט data_collator.

 

כוונון fine-tuning המודל המאומן של hugging face

לאמן מודל טרנספורמר לוקח המון זמן ודורש מסד נתונים ענק וכמות של משאבי מחשוב שאין בכל בית לכן אנחנו משתמשים במודל מאומן. מה שאומר שאנחנו לוקחים את כל המשקולות שלו חוץ מהשכבה האחרונה אותה אנחנו צריכים להתאים לצרכינו. במקרה שלנו, אנחנו מצפים מהמודל לסווג לאחת משתי קטגוריות. עם hugging face המשימה קלה לביצוע. נגדיר את מספר הקטגוריות (2 במקרה שלנו):

from transformers import TFAutoModelForSequenceClassification
 
model = TFAutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)

נקמפל ונריץ:

optimizer = tf.keras.optimizers.Adam(learning_rate=3e-5, epsilon=1e-08, clipnorm=1.0)
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
metric = tf.keras.metrics.SparseCategoricalAccuracy('accuracy')
 
model.compile(optimizer=optimizer, loss=loss, metrics=[metric])
 
model.fit(tf_train_dataset,
         validation_data=tf_validation_dataset,
         batch_size=BATCH_SIZE,
         epochs=3)
Epoch 1/3
244/244 [==============================] - 120s 375ms/step - loss: 0.0998 - accuracy: 0.9682 - val_loss: 0.0793 - val_accuracy: 0.9830
Epoch 2/3
244/244 [==============================] - 94s 384ms/step - loss: 0.0384 - accuracy: 0.9905 - val_loss: 0.0262 - val_accuracy: 0.9929
Epoch 3/3
244/244 [==============================] - 95s 391ms/step - loss: 0.0177 - accuracy: 0.9949 - val_loss: 0.0219 - val_accuracy: 0.9946
keras.callbacks.History at 0x7fe7b3faab90

 

נעריך את ביצועי המודל

נעריך את מידת הדיוק על קבוצת המבחן שלא השתתפה בתהליך האימון:

loss, acc = model.evaluate(tf_test_dataset)
35/35 [==============================] - 4s 122ms/step - loss: 0.0383 - accuracy: 0.9946
  • 99.5% דיוק

באילו מקרים שגה המודל?

from sklearn.metrics import confusion_matrix, accuracy_score, classification_report, f1_score
 
y_pred = model.predict(tf_test_dataset)
y_pred = np.argmax(y_pred[0],axis=1)
 
 
y_actual = []
for bx in tf_test_dataset:
  ax = bx[1].numpy()
  y_actual.extend(ax)
 
print(confusion_matrix(y_actual, y_pred))
[[477   1]
 [  2  72]]
print(classification_report(y_actual, y_pred))
             precision    recall  f1-score   support
 
           0       0.99      1.00      1.00       478
           1       0.99      0.97      0.98        74
 
    accuracy                           0.99       552
   macro avg       0.99      0.99      0.98       552
weighted avg       0.99      0.99      0.99       552

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

for idx, x in enumerate(y_actual):
  if y_actual[idx] != y_pred[idx]:
      print('text: ', dataset['test'][idx]['Message'])
      print('label: ', dataset['test'][idx]['cat_int'])
      print('pred: ', y_pred[idx])
text:  Did you hear about the new Divorce Barbie"? It comes with all of Ken's stuff!"
label:  1
pred:  0
 
text:  We are pleased to inform that your application for Airtel Broadband is processed successfully. Your installation will happen within 3 days.
label:  0
pred:  1
 
text:  Hello darling how are you today? I would love to have a chat, why dont you tell me what you look like and what you are in to sexy?
label:  1
pred:  0
  • התוכן של ההודעות תואם את הסיווג במסד הנתונים ולכן ההסבר החלופי לפיו מי שסיווג את מסד הנתונים שגה לא מתקבל.

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

 

סיכום

המודל מצליח להבחין בין הודעות ספאם ללגיטימיות ב-99.5% מהמקרים. ב-1 מתוך 478 מקרים (0.2%) הוא סיווג בטעות הודעה לגיטימית כאילו היתה ספאם. מה שעלול להיות בעייתי כי למרות שיעור ה- false negative הנמוך משתמש המצפה להודעה עלול להתאכזב אם המודל יסווג אותה "ספאם" וכתוצאה מכך היא תגיע לפח האשפה. פחות בעייתי הוא המצב בו הודעות ספאם מסוימות מצליחות להסתנן להודעות הלגיטימיות. מה שקרה ל-2 מתוך 74 הודעות ספאם (2.7%).

בסדרה של מדריכים בתחום של למידת מכונה ניסיתי למצוא את השיטה היעילה ביותר לזיהוי הודעות SMS ספאמיות. הגישה של RNN לזיהוי ספאם הניבה את התוצאות הכי פחות טובות עם 98.5% דיוק ו-3 מתוך 478 הודעות תקינות שזוהו כספאם False Positive. הגישה של bag of words היתה יותר מוצלחת עם 99.3% דיוק, ללא False Positives ויכולתי להריץ את המודל ללא בעיות על המחשב הביתי בלי עזרת GPU. המודל במדריך הנוכחי, מבוסס על מודל טרנספורמר Bert מאומן מראש pre-trained שאני עובד איתו דרך ממשק hugging face והוא הגיע ל-99.5% כאשר 1 הדוגמאות הלגיטימיות סווגה בטעות כספאם ואימון המודל מצריך שימוש ב- GPU.

לפי התוצאות הנטייה שלי היא לבחור במודל bag of words כי הוא דורש פחות משאבי מחשוב והתוצאות שלו הם מדויקות כמו מודל טרנספורמר. אמנם היום הנטייה היא להשתמש בטרנספורמרים למטרות של עיבוד שפה אנושית NLP כי הם המתוחכמים ביותר שיש לנו בארסנל, אבל על פי מה שאני רואה, ככל שהדברים נוגעים למשימה הספציפית של סיווג הודעות SMS, ובהסכמה עם התער של אוקאם, אני מעדיף את החלופה הפשוטה יותר.

 

אולי גם זה יעניין אותך

הטרנספורמרים משנים את עולם הבינה המלאכותית

זיהוי SMS ספאמי בעזרת בינה מלאכותית

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

 

לכל המדריכים בנושא של למידת מכונה

 

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

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

 

 

הוסף תגובה חדשה

 

= 7 + 5