Классификация текста: наивный байес на практике
Наивный байес — простой, быстрый и на удивление сильный классификатор текста; разберём его на работающем спам-фильтре.
Наивный байесовский классификатор — модель, которая выбирает класс с наибольшей вероятностью по теореме Байеса, «наивно» предполагая, что слова в тексте независимы друг от друга.
Идея на пальцах
Мы хотим понять: данное письмо — спам или нет? Байес переворачивает вопрос. Вместо «какова вероятность спама при этих словах» он считает «какова вероятность встретить эти слова, если письмо — спам» и «…если письмо нормальное», а потом сравнивает. Класс, при котором наблюдаемые слова вероятнее, и выигрывает.
«Наивность» — в допущении, что слова независимы. В жизни это не так («бесплатный» и «кредит» ходят вместе), но допущение сильно упрощает расчёты и на практике работает хорошо.
Что считаем
Для каждого класса (спам / не спам) нам нужны:
- P(класс) — доля писем этого класса в обучении.
- P(слово | класс) — вероятность слова внутри класса = (сколько раз слово встречалось в письмах класса + 1) / (всего слов в классе + размер словаря). «+1» — сглаживание Лапласа, чтобы незнакомое слово не обнуляло всё произведение.
Для нового письма перемножаем вероятности его слов внутри каждого класса (умножаем на P(класс)) и выбираем класс с большим итогом. На практике складывают логарифмы вероятностей, чтобы не получить слишком маленькие числа.
Спам-фильтр целиком, запускаемый
import math
from collections import Counter, defaultdict
# обучающий корпус: (текст, класс)
train = [
("выигрыш бесплатно кредит", "spam"),
("бесплатно деньги выигрыш", "spam"),
("кредит деньги срочно", "spam"),
("встреча завтра в офисе", "ham"),
("привет как твои дела", "ham"),
("обед в час дня", "ham"),
]
# словарь и счётчики по классам
vocab = set()
class_words = defaultdict(Counter) # класс -> Counter слов
class_total = defaultdict(int) # класс -> всего слов
class_docs = Counter() # класс -> число документов
for text, label in train:
words = text.split()
class_docs[label] += 1
for w in words:
vocab.add(w)
class_words[label][w] += 1
class_total[label] += 1
V = len(vocab)
N = len(train)
def log_prob(words, label):
# лог P(класс) + сумма лог P(слово|класс) со сглаживанием Лапласа
lp = math.log(class_docs[label] / N)
for w in words:
count = class_words[label][w]
lp += math.log((count + 1) / (class_total[label] + V))
return lp
def classify(text):
words = text.split()
scores = {c: log_prob(words, c) for c in class_docs}
return max(scores, key=scores.get), scores
for msg in ["бесплатно кредит срочно", "привет как дела на обеде"]:
label, scores = classify(msg)
print(msg, "->", label)
print(" log P(spam)=%.2f log P(ham)=%.2f" % (scores["spam"], scores["ham"]))
Вывод:
бесплатно кредит срочно -> spam log P(spam)=-7.46 log P(ham)=-10.69 привет как дела на обеде -> ham log P(spam)=-16.79 log P(ham)=-15.27
Классификатор уверенно отнёс «бесплатно кредит срочно» к спаму, а «привет как дела на обеде» — к нормальным письмам. И всё это — на шести обучающих примерах, простым перемножением вероятностей слов. Никаких нейросетей.
Почему «+1» так важно
Если слово ни разу не встретилось в классе, его вероятность была бы 0, и всё произведение обнулилось бы — один незнакомый токен «убил» бы класс. Сглаживание Лапласа (+1 к каждому счётчику) даёт таким словам маленькую, но ненулевую вероятность.
Почему логарифмы
Перемножение многих вероятностей (каждая < 1) даёт исчезающе малое число, которое теряет точность. Логарифм превращает произведение в сумму, с которой компьютер работает надёжно. Сравнение сумм логарифмов эквивалентно сравнению произведений.
Итог
- Наивный байес выбирает класс, при котором слова текста вероятнее всего.
- «Наивность» — допущение независимости слов; упрощает расчёт, но работает.
- Сглаживание Лапласа (+1) спасает от обнуления на незнакомых словах.
- Складывают логарифмы вероятностей вместо перемножения — ради устойчивости.
- Несмотря на простоту, это сильный базовый классификатор текста.