Если вы похожи на меня, вы наверняка задавались вопросом, почему ваш почтовый ящик не завален письмами с предложениями увеличить то, что точно не нуждается в увеличении. Ответ кроется в машинном обучении, а именно в обманчиво простом, но удивительно эффективном алгоритме, называемом наивным Байесовским классификатором. Сегодня мы создадим спам-фильтр, который заставит инженеров любого почтового сервиса одобрительно кивать (или по крайней мере не смеяться над нашим кодом).
Проблема, которую мы решаем
Спам — это как незваный гость на вечеринке, который не хочет уходить. Только вместо одного человека, портящего вам вечер, у вас тысячи сообщений, забивающих почтовый ящик каждый день. Поставщики электронной почты фильтруют миллиарды сообщений, и у них есть для этого сложные системы. Но вот в чём прелесть: мы можем создать нечто удивительно эффективное, используя всего лишь Python и некоторое статистическое мышление.
Наивный Байесовский классификатор был основой фильтрации спама с начала 2000-х годов, и хотя нейронные сети сегодня получают все похвалы, этот алгоритм остаётся невероятно практичным. Почему? Потому что он быстрый, понятный и работает. Это как швейцарский армейский нож для классификации текста — не модный, но свою работу делает.
Понимание наивного Байеса: теория (без головной боли)
Прежде чем писать код, давайте поговорим о том, как работает этот алгоритм. Не волнуйтесь, я сделаю математику доступной.
Наивный Байесовский классификатор основан на простом принципе: он вычисляет вероятность того, что сообщение является спамом, учитывая его слова. Математически мы ищем:
$P(\text{Спам}|w_1, w_2, …, w_n)$
Где w₁ через w_n — это слова в нашем сообщении.
Используя теорему Байеса, это становится:
$P(\text{Спам}|w_1, w_2, …, w_n) = \frac{P(w_1, w_2, …, w_n|\text{Спам}) \times P(\text{Спам})}{P(w_1, w_2, …, w_n)}$
Здесь вступает «наивная» часть — мы предполагаем, что каждое слово независимо от каждого другого слова. В реальности это не так. Наличие слова «ПОБЕДИТЕЛЬ» делает слово «ПОЗДРАВЛЯЕМ» более вероятным, а не независимым. Но это наивное предположение на практике работает блестяще и делает математику выполнимой.
Мы вычисляем вероятность появления каждого слова в спам- и легитимных сообщениях, затем перемножаем эти вероятности, чтобы получить окончательный вердикт.
Набор данных: ваша тренировочная площадка
Мы будем использовать общедоступный набор данных из 5 572 SMS-сообщений, собранных Tiago A. Almeida и José María Gómez Hidalgo. Он удивительно прост: каждое сообщение помечено как «ham» (легитимное) или «spam». Вы можете взять его из репозитория машинного обучения UCI, и, честно говоря, это идеальная площадка для обучения, потому что он не слишком маленький и не до обидного огромный.
Разбиение набора данных простое: 80% для обучения, 20% для тестирования. Наша цель? Достичь точности более 80% — хотя с наивным Байесовским классификатором, применённым к этому набору данных, вы, вероятно, превзойдёте этот показатель.
Создание нашего спам-фильтра с нуля
Позвольте мне показать вам пошаговый процесс. В этом разделе мы создаём фильтр вручную, чтобы вы поняли, что происходит под капотом.
Шаг 1: загрузка и исследование данных
import pandas as pd
import numpy as np
from collections import defaultdict
import re
# Загрузка набора данных
url = "https://raw.githubusercontent.com/justmarkham/DAT8/master/data/sms.csv"
sms_spam = pd.read_csv(url)
# Переименование столбцов для ясности
sms_spam.columns = ['Label', 'SMS']
print(sms_spam.head())
print(sms_spam['Label'].value_counts())
print(f"Общее количество сообщений: {len(sms_spam)}")
Это даёт нам краткий обзор. Вы заметите, что набор данных несбалансирован — легитимных сообщений значительно больше, чем спам. Это реалистично, потому что, к счастью, большинство электронных писем не являются спамом.
Шаг 2: разделение на обучающую и тестовую выборки
# Перемешивание набора данных
data_randomized = sms_spam.sample(frac=1, random_state=1)
# Расчёт индекса для разделения 80-20
training_test_index = round(len(data_randomized) * 0.8)
# Разделение на обучающую и тестовую выборки
training_set = data_randomized[:training_test_index].reset_index(drop=True)
test_set = data_randomized[training_test_index:].reset_index(drop=True)
print(f"Размер обучающей выборки: {training_set.shape}")
print(f"Размер тестовой выборки: {test_set.shape}")
Перемешивание здесь важно — мы не хотим, чтобы модель учила паттерны из исходного порядка набора данных.
Шаг 3: очистка текста
Здесь начинается настоящая работа. Сырой текст грязный.
def clean_text(text):
"""
Очищает текст, преобразуя его в нижний регистр, удаляя пунктуацию
и разбивая на слова
"""
# Преобразование в нижний регистр
text = str(text).lower()
# Удаление пунктуации и специальных символов
text = re.sub(r'[^a-z0-9\s]', '', text)
# Разбиение на слова
words = text.split()
return words
# Создание очищенной версии обучающей выборки
training_set_clean = training_set.copy()
training_set_clean['SMS'] = training_set_clean['SMS'].apply(clean_text)
print("Исходное сообщение:", training_set.iloc['SMS'])
print("Очищенное сообщение:", training_set_clean.iloc['SMS'])
Заметьте, насколько агрессивно мы подходим к очистке? Мы удаляем почти всё, кроме букв и цифр. Это может показаться жестоким, но это предотвращает запоминание моделью специфических особенностей форматирования, которые не будут обобщаться.
Шаг 4: создание словаря
# Создание словаря из обучающей выборки
vocabulary = set()
for message in training_set_clean['SMS']:
vocabulary.update(message)
vocabulary = sorted(list(vocabulary))
print(f"Размер словаря: {len(vocabulary)}")
print(f"Первые 50 слов: {vocabulary[:50]}")
Словарь — это просто каждое уникальное слово, которое встречается в наших обучающих сообщениях. Для этого набора данных вы обычно получите около 7 000–10 000 уникальных слов.
Шаг 5: создание таблиц частот слов
Здесь мы создаём нашу вероятностную модель:
# Создание таблиц частот слов для спама и легитимных сообщений
word_frequencies_spam = defaultdict(int)
word_frequencies_ham = defaultdict(int)
# Проход по обучающей выборке
for idx, row in training_set_clean.iterrows():
if row['Label'] == 'spam':
for word in row['SMS']:
word_frequencies_spam[word] += 1
else:
for word in row['SMS']:
word_frequencies_ham[word] += 1
# Расчёт априорных вероятностей
total_spam = (training_set['Label'] == 'spam').sum()
total_ham = (training_set['Label'] == 'ham').sum()
p_spam = total_spam / len(training_set)
p_ham = total_ham / len(training_set)
print(f"P(Спам) = {p_spam:.4f}")
print(f"P(Легитимное) = {p_ham:.4f}")
# Общее количество слов в спам и легитимных сообщениях
total_words_spam = sum(word_frequencies_spam.values())
total_words_ham = sum(word_frequencies_ham.values())
print(f"Общее количество слов в спам-сообщениях: {total_words_spam}")
print(f"Общее количество слов в легитимных сообщениях: {total_words_ham}")
Шаг 6: решение проблемы нулевой вероятности
Одна критическая проблема: если слово появляется в нашем тестовом сообщении, но никогда не появлялось в обучающих спам-сообщениях, его вероятность будет равна нулю, и всё рухнет. Мы решаем это с помощью сглаживания Лапласа (добавляя 1 ко всем подсчётам):
# Параметр сглаживания Лапласа
alpha = 1
# Расчёт условных вероятностей P(слово|спам) и P(слово|легитимное)
def calculate_conditional_prob(word, label_type, alpha):
"""
