Наивный Байесовский классификатор для фильтрации спама

Использование наивного Байесовского классификатора для фильтрации спама — довольно старый и известный способ фильтрации сообщений. В его основе лежит простая идея – на обучающих данных для каждого слова в сообщении вычисляется оценка вероятности того, что письмо с этим словом является спамом и, исходя их этого, для вновь пришедшего письма на основании всех слов, вычисляется его вероятность оказаться спамом. Это модель предполагает серьезное допущение, что вероятность встретить одно слово не зависит от вероятности встретить другое слово в спамном или неспамном сообщении (отсюда и наивность в названии), однако до сих пор такой классификатор может использоваться и показывать хорошие результаты, даже по сравнению с более продвинутыми методами.

Рассмотрим подробнее как данный алгоритм можно пошагово реализовать на python. Для этого возьмем данные с сайта Kaggle (SMS Spam Collection Dataset). В датасете содержится информация о 5572 сообщений, среди которых 13% спама.

Наивный Байесовский классификатор для фильтрации спама

Разделим данные на обучающую и тестовую выборки. Сохраним обучающий датасет в переменную df. Для начала напишем простую вспомогательную функцию для того, что бы получить множество всех слов:

import re def tokenize(message): message = message.lower() all_words = re.findall("[a-z0-9]+", message) return set(all_words)

Теперь перейдем непосредственно к алгоритму. Для начала для каждого слова нужно найти оценку вероятности встретить его в спамном или неспамном сообщении. Для этого просто посчитаем сколько раз каждое слово встречается в каждом из типов сообщений. Также напишем функцию под это:

from collections import defaultdict import pandas as pd def count_words(data): counts = defaultdict(lambda: [0,0]) for index, row in data.iterrows(): for word in tokenize(row['text']): counts[word][0 if row['spam'] else 1] +=1 return counts

Воспользуемся этой функцией и сохраним результат в переменную freq:

freq = count_words(df)

В результате получаем словарь, следующего вида:

Наивный Байесовский классификатор для фильтрации спама

Мы можем видеть каждое слово и сколько раз оно встречается в каждом типе сообщений. Теперь переходим к вычислению веса, то есть нам необходимо поделить конкретную частоту на общее количество сообщений каждого типа. Но здесь есть еще одно допущение. Если какое-то слово присутствует только в сообщениях без спама, то сообщение с этим словом классификатор всегда будет относить к неспамному. Что бы этого избежать при расчете вероятности добавим коэффициент сглаживания равный единицы. Это позволит избежать нулевые вероятности.

Итак идем дальше. Сохраним в переменных общее число сообщений со спамом и без него:

all_spam = df['spam'].loc[df['spam'] == 1].count() all_not_spam = df['spam'].loc[df['spam'] == 0].count()

Напишем очередную функцию. Она будет возвращать для каждого слова уже посчитанную вероятность:

def prob(freq, all_spam, all_not_spam, k = 1): return [ (word, (frequency[0] + k) / (all_spam + 2*k), (frequency[1] + k) / (all_not_spam + 2*k)) for word, frequency in counts.items()]

Воспользуемся функцией и сохраним результат в переменной result:

result = prob(freq, all_spam, all_not_spam)

Получим данные следующего вида:

Наивный Байесовский классификатор для фильтрации спама

Теперь мы можем посчитать вероятности для сообщений. Также сделаем это с помощью функции. По идее мы должны перемножить частные вероятностные друг на друга. Но так как вероятности могут представлять собой очень маленькие числа, их перемножение может привести к исчезновению разрядов. Поэтому воспользуемся алгебраическими свойствами и заменим действия на сумму логарифмов:

import math def final(word_probs, message): # Также обрабатываются сообщения message_words = tokenize(message) # изначально вероятности спама и неспама равны нулю spam_prob = not_spam_prob = 0.0 # наращиваем вероятности в циклах for word, prob_if_spam, prob_if_not_spam in word_probs: # если слово в сообщении используем посчитанную вероятность if word in message_words: spam_prob += math.log(prob_if_spam) not_spam_prob += math.log(prob_if_not_spam) # если слова нет в сообщении используем вероятность противоположного события, т.е 1 - посчитанная вероятность else: spam_prob += math.log(1.0 - prob_if_spam) not_spam_prob += math.log(1.0 - prob_if_not_spam) e_spam_prob = math.exp(spam_prob) e_not_spam_prob = math.exp(not_spam_prob) # непосредственно формула вероятность по теореме Баейса return e_spam_prob/(e_spam_prob+e_not_spam_prob)

Из тестового набора данных собираем датасет с вероятностями:

for index, row in test.iterrows(): df_test = df_test.append({'text': row['text'],'prob': final(result, row['text']), 'spam_': row['spam']}, ignore_index=True)

Получаем датасет вида:

Наивный Байесовский классификатор для фильтрации спама

Осталось только поставить порог отнесения к классу на вероятность и проверить работу алгоритма на тестовых данных.Так как в результате работы предыдущих функций мы получили значение вероятности, от него необходимо перейти непосредственно к метке. Данная функция возвращает метку спама исходя из полученной вероятности:

def func(row): if row['prob']>0.5: return 1 else: return 0 df_test['spam_final'] = df_test.apply(func, axis = 1) accuracy_score(df['spam'], df['spam_final'])

Для работы этой функции необходимо выбрать порог по которому по вероятности будет присваиваться метка. От выбора этого порога будет зависеть направленность фильтра. Если выбрать минимальный порог, то любое подозрительное сообщение будет отправляться в спам, а это означает что некоторое число «хороших» сообщений будут отнесены в спам. Если выбрать слишком высокий порог, то некоторое число спамных сообщений все же сможет обойти фильтр. Таким образом порог следует выбирать исходя из того, ошибки какого типа для нас в данном случае более приемлемы.

Посмотрим как это будет работать при изменение порога. Для начала попробуем установить порог ровно посередине вероятности – 50%. Для порога в 50% точность составила 76,88%. Попробуем немного сдвинуть. Повторим те же действия для порога в 60% и получим точность уже 98,42%. Получаем очень хороший результат для такой простой модели.

1010
3 комментария

Хорошая статья о применении байесовского классификатора, но есть пара замечаний. Несколько спорным выглядит подсчёт вероятностей встречаемости слов в спам и не-спам сообщениях сразу на всем датасете. Таким образом даже используя сглаживание вы допускаете утечку таргета для сообщений из тестового множества в случае редких слов, что в дальнейшем скажется при подсчёте качества модели.
Есть ошибка в функции prob — вместо counts видимо хотели использовать её первый аргумент.
И, пожалуйста, используйте apply или loc вместо iterrows - он производит обертку каждой строки в отдельный Series и за счёт этого медленнее apply минимум в 2 раза.
Оценивать модель через точность хорошая идея, но в этой задаче нам несколько мешает дисбаланс классов - даже если бы модель всегда выдавала 0, мы бы получили 87% точности. Можно считать ROC AUC, который от этих проблем избавлен. Также легко гуглится определение оптимального порога классификации при помощи ROC кривой, если не хочется подбирать наугад.

1
Автор

Спасибо за комментарий! Действительно, полезные советы. Данная статья написана, чтобы скорее отразить общую идею применения такого метода, ее можно продолжать совершенствовать.

А если проверить - есть ли у письма марка? Если есть - то это дорогой спам, а если нет - то точно спам.
Письма с адресов апрувленных владельцем почтового ящика вряд ли будут спамом. Остальные - за борт.
Еще можно присылать один цифровой доллар адресату с тем чтобы если все ок, в ответ этот доллар присылался назад.
А если не ок - сами понимаете.