Практика 11 · Наївний Баєс¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє:homework.md· 🧪 Тест:quiz.html
У лекції наївний Баєс показано ззовні: сітка зі ста оголошень, смужки внесків, повзунок згладжування. Тут ми зберемо його з нуля — так, щоб не лишилось жодного місця, де «бібліотека щось порахувала сама».
Що зробимо:
- Зберемо той самий архів зі ста оголошень про вживані телефони, що в лекції
- Порахуємо теорему Баєса на одному слові й повторимо пастку з апріорною ймовірністю
- Перевіримо на даних, наскільки хибне «наївне» припущення про незалежність
- Класифікуємо конкретне оголошення покроково й звіримо кожне число з
MultinomialNB - Вимкнемо згладжування Лапласа й побачимо, як усе ламається
- Перейдемо на логарифми й переконаємось, що добуток без них провалюється в нуль
- Наостанок візьмемо числові ознаки, запустимо
GaussianNBі порівняємо його з kNN із теми 18 на тих самих даних
1. Архів: сто оголошень і сім слів¶
Той самий архів, що в лекції: 100 оголошень, з них 28 виявились приманками. Про кожне відомо, які з семи слів словника є в його тексті.
Щоб числа тут збігалися з лекцією до останньої цифри, ми не кидаємо монетку, а ставимо
слова в наперед визначені оголошення. Функція поставити_слово робить рівно це.
import numpy as np
СЛОВА = ["терміново", "передоплата", "новий", "чек", "гарантія", "дешево", "торг"]
# рядок — оголошення, колонка — слово; 1 означає «слово є в тексті»
слова_в_оголошеннях = np.zeros((100, 7), dtype=int)
# перші 28 рядків — шахрайські, решта 72 — чесні
шахрайство = np.zeros(100, dtype=int)
шахрайство[:28] = 1
def поставити_слово(слово, від_рядка, до_рядка):
'''Ставить слово в діапазон оголошень включно з обома краями.
Явні діапазони потрібні, щоб архів збігся з таблицею лекції не лише за
кількостями, а й за тим, які слова стоять в одному оголошенні разом.
'''
номер = СЛОВА.index(слово)
слова_в_оголошеннях[від_рядка:до_рядка + 1, номер] = 1
# шахрайські оголошення (рядки 0-27)
поставити_слово("передоплата", 0, 20)
поставити_слово("терміново", 4, 22)
поставити_слово("новий", 0, 7)
поставити_слово("гарантія", 25, 27)
поставити_слово("дешево", 6, 21)
поставити_слово("торг", 21, 27)
# «чек» у шахрайських не ставимо жодного разу — приманки не пропонують чек
# чесні оголошення (рядки 28-99)
поставити_слово("терміново", 28, 36)
поставити_слово("передоплата", 34, 39)
поставити_слово("новий", 28, 55)
поставити_слово("чек", 56, 86)
поставити_слово("гарантія", 60, 85)
поставити_слово("дешево", 43, 53)
поставити_слово("торг", 67, 99)
print("оголошень усього:", len(шахрайство))
print("шахрайських:", int(шахрайство.sum()), "· чесних:", int((1 - шахрайство).sum()))
оголошень усього: 100 шахрайських: 28 · чесних: 72
Тепер порахуємо, скільки разів кожне слово трапилось у кожному класі. Ці два рядки чисел — і є вся «навчена» частина мультиноміального наївного Баєса.
import pandas as pd
лічильники_шахр = слова_в_оголошеннях[шахрайство == 1].sum(axis=0)
лічильники_чесн = слова_в_оголошеннях[шахрайство == 0].sum(axis=0)
таблиця = pd.DataFrame({
"у шахрайських": лічильники_шахр,
"у чесних": лічильники_чесн,
}, index=СЛОВА)
таблиця.loc["РАЗОМ"] = [лічильники_шахр.sum(), лічильники_чесн.sum()]
print(таблиця)
у шахрайських у чесних терміново 19 9 передоплата 21 6 новий 8 28 чек 0 31 гарантія 3 26 дешево 16 11 торг 7 33 РАЗОМ 74 144
Числа мають збігтися з таблицею лекції. Перевіримо це не очима, а assert — щоб зошит
сам ловив розходження, якщо колись хтось змінить генерацію архіву.
# ті самі числа, що надруковані в розділі 4 лекції
assert list(лічильники_шахр) == [19, 21, 8, 0, 3, 16, 7], "архів розійшовся з лекцією!"
assert list(лічильники_чесн) == [9, 6, 28, 31, 26, 11, 33], "архів розійшовся з лекцією!"
print("✅ архів збігається з таблицею лекції")
✅ архів збігається з таблицею лекції
2. Теорема Баєса на одному слові¶
Питання лекції: в оголошенні є слово «передоплата» — яка ймовірність, що воно шахрайське? Порахуємо двома способами: спершу просто поділивши кількості, потім за формулою. Числа мають зійтися.
є_передоплата = слова_в_оголошеннях[:, СЛОВА.index("передоплата")] == 1
шахрайських_зі_словом = int(шахрайство[є_передоплата].sum())
усього_зі_словом = int(є_передоплата.sum())
print("оголошень зі словом «передоплата»:", усього_зі_словом)
print("з них шахрайських:", шахрайських_зі_словом)
print("P(шахрайство | слово) =", шахрайських_зі_словом, "/", усього_зі_словом,
"=", round(шахрайських_зі_словом / усього_зі_словом, 4))
оголошень зі словом «передоплата»: 27 з них шахрайських: 21 P(шахрайство | слово) = 21 / 27 = 0.7778
# те саме за формулою Баєса: P(A|B) = P(B|A) * P(A) / P(B)
апріорна_шахрайство = шахрайство.mean() # частка шахраїв до будь-яких свідчень
правдоподібність = є_передоплата[шахрайство == 1].mean() # як часто шахраї пишуть це слово
ймовірність_слова = є_передоплата.mean() # як часто слово трапляється взагалі
за_формулою = правдоподібність * апріорна_шахрайство / ймовірність_слова
print(f"P(шахрайство) = {апріорна_шахрайство:.4f}")
print(f"P(слово | шахрайство) = {правдоподібність:.4f}")
print(f"P(слово) = {ймовірність_слова:.4f}")
print(f"P(шахрайство | слово) = {за_формулою:.4f}")
assert np.isclose(за_формулою, шахрайських_зі_словом / усього_зі_словом)
print("✅ формула дає рівно те саме, що ділення кількостей")
P(шахрайство) = 0.2800 P(слово | шахрайство) = 0.7500 P(слово) = 0.2700 P(шахрайство | слово) = 0.7778 ✅ формула дає рівно те саме, що ділення кількостей
3. Пастка апріорної ймовірності¶
Головний розділ лекції: коли шуканий клас рідкісний, більшість спрацювань ознаки — хибні тривоги. Порахуємо це для дошки, де шахраїв лише 10 зі ста, а ознака спрацьовує на 80% шахраїв і помилково — на 20% чесних.
def скільки_спрацювань(шахраїв_зі_ста, надійність):
'''Повертає, скільки разів ознака спрацює і скільки з них буде правдою.
Рахуємо в оголошеннях, а не у відсотках: так видно, що велика група чесних
дає більше хибних тривог, ніж уся мала група шахраїв разом узята.
'''
чесних = 100 - шахраїв_зі_ста
справжніх_тривог = round(шахраїв_зі_ста * надійність)
хибних_тривог = round(чесних * (1 - надійність))
return справжніх_тривог, хибних_тривог
справжніх, хибних = скільки_спрацювань(шахраїв_зі_ста=10, надійність=0.80)
print("справжніх тривог:", справжніх)
print("хибних тривог: ", хибних)
print("усього спрацювань:", справжніх + хибних)
print("P(шахрайство | ознака) =", round(справжніх / (справжніх + хибних), 4))
справжніх тривог: 8 хибних тривог: 18 усього спрацювань: 26 P(шахрайство | ознака) = 0.3077
# як відповідь залежить від того, наскільки рідкісний клас
for шахраїв in [2, 5, 10, 20, 28, 50]:
справжніх, хибних = скільки_спрацювань(шахраїв, надійність=0.80)
ймовірність = справжніх / (справжніх + хибних)
print(f"шахраїв {шахраїв:>2} зі 100 → спрацювань {справжніх + хибних:>2}, "
f"з них справжніх {справжніх:>2} → P = {ймовірність:.3f}")
шахраїв 2 зі 100 → спрацювань 22, з них справжніх 2 → P = 0.091 шахраїв 5 зі 100 → спрацювань 23, з них справжніх 4 → P = 0.174 шахраїв 10 зі 100 → спрацювань 26, з них справжніх 8 → P = 0.308 шахраїв 20 зі 100 → спрацювань 32, з них справжніх 16 → P = 0.500 шахраїв 28 зі 100 → спрацювань 36, з них справжніх 22 → P = 0.611 шахраїв 50 зі 100 → спрацювань 50, з них справжніх 40 → P = 0.800
Ознака та сама, надійність та сама — а відповідь змінюється від 9% до 80% лише через те, скільки шахраїв на дошці. Саме тому «модель сказала 90%» без відповіді на питання «а скільки шахраїв тут узагалі?» не означає нічого.
4. Наскільки хибне «наївне» припущення¶
Модель вважає, що слова всередині класу трапляються незалежно. Перевіримо це на двох словах, які підозріло схожі за змістом: «терміново» і «передоплата».
шахрайські_тексти = слова_в_оголошеннях[шахрайство == 1]
частка_терміново = шахрайські_тексти[:, СЛОВА.index("терміново")].mean()
частка_передоплата = шахрайські_тексти[:, СЛОВА.index("передоплата")].mean()
обидва_разом = ((шахрайські_тексти[:, СЛОВА.index("терміново")] == 1)
& (шахрайські_тексти[:, СЛОВА.index("передоплата")] == 1))
частка_обох_насправді = обидва_разом.mean()
частка_обох_за_наївним = частка_терміново * частка_передоплата
скільки_шахрайських = len(шахрайські_тексти)
print(f"«терміново» є в {частка_терміново:.3f} шахрайських оголошень")
print(f"«передоплата» є в {частка_передоплата:.3f}")
print(f"обидва разом за наївним припущенням: {частка_обох_за_наївним:.3f} "
f"({частка_обох_за_наївним * скільки_шахрайських:.2f} оголошень)")
print(f"обидва разом насправді: {частка_обох_насправді:.3f} "
f"({обидва_разом.sum()} оголошень)")
print(f"реальність перевищує припущення у {частка_обох_насправді / частка_обох_за_наївним:.2f} раза")
«терміново» є в 0.679 шахрайських оголошень «передоплата» є в 0.750 обидва разом за наївним припущенням: 0.509 (14.25 оголошень) обидва разом насправді: 0.607 (17 оголошень) реальність перевищує припущення у 1.19 раза
Припущення хибне — і ми це щойно виміряли. Далі побачимо, що метод усе одно працює: для вироку потрібне не точне значення ймовірності, а лише те, який із двох добутків більший.
5. Класифікація крок за кроком¶
Оголошення: «Терміново продам, новий, лише передоплата». Порахуємо дві оцінки — за шахрайство й за чесність, — перемноживши апріорну ймовірність класу на ймовірності всіх слів тексту в цьому класі.
Поки що без згладжування: просто ділимо кількість слова на кількість усіх слів класу.
усього_слів_шахр = лічильники_шахр.sum()
усього_слів_чесн = лічильники_чесн.sum()
ймовірності_шахр = лічильники_шахр / усього_слів_шахр
ймовірності_чесн = лічильники_чесн / усього_слів_чесн
for номер, слово in enumerate(СЛОВА):
print(f"{слово:<13} P(·|шахр) = {ймовірності_шахр[номер]:.6f} "
f"P(·|чесн) = {ймовірності_чесн[номер]:.6f}")
терміново P(·|шахр) = 0.256757 P(·|чесн) = 0.062500 передоплата P(·|шахр) = 0.283784 P(·|чесн) = 0.041667 новий P(·|шахр) = 0.108108 P(·|чесн) = 0.194444 чек P(·|шахр) = 0.000000 P(·|чесн) = 0.215278 гарантія P(·|шахр) = 0.040541 P(·|чесн) = 0.180556 дешево P(·|шахр) = 0.216216 P(·|чесн) = 0.076389 торг P(·|шахр) = 0.094595 P(·|чесн) = 0.229167
оголошення = ["терміново", "новий", "передоплата"]
апріорна_чесність = 1 - апріорна_шахрайство
оцінка_шахр = апріорна_шахрайство
оцінка_чесн = апріорна_чесність
print(f"старт: апріорні {апріорна_шахрайство:.2f} проти {апріорна_чесність:.2f}")
for слово in оголошення:
номер = СЛОВА.index(слово)
оцінка_шахр = оцінка_шахр * ймовірності_шахр[номер]
оцінка_чесн = оцінка_чесн * ймовірності_чесн[номер]
print(f"після «{слово}»: шахр = {оцінка_шахр:.8f}, чесн = {оцінка_чесн:.8f}")
ймовірність_шахрайства = оцінка_шахр / (оцінка_шахр + оцінка_чесн)
print()
print(f"оцінка за шахрайство більша у {оцінка_шахр / оцінка_чесн:.2f} раза")
print(f"P(шахрайство | текст) = {ймовірність_шахрайства:.6f}")
старт: апріорні 0.28 проти 0.72 після «терміново»: шахр = 0.07189189, чесн = 0.04500000 після «новий»: шахр = 0.00777210, чесн = 0.00875000 після «передоплата»: шахр = 0.00220559, чесн = 0.00036458 оцінка за шахрайство більша у 6.05 раза P(шахрайство | текст) = 0.858149
Числа мають збігтися з розділом 5 лекції: оцінки 0.002206 і 0.000365, відношення 6.05,
підсумкові 86%. А тепер найцікавіше — те саме через MultinomialNB.
alpha в бібліотеці і є згладжуванням Лапласа. Щоб відтворити наш розрахунок без
згладжування, ставимо його мінімально можливим.
from sklearn.naive_bayes import MultinomialNB
def текст_у_рядок(слова_тексту):
'''Перетворює список слів на рядок 0/1 у порядку словника — саме такий вхід чекає sklearn.'''
рядок = np.zeros((1, len(СЛОВА)), dtype=int)
for слово in слова_тексту:
рядок[0, СЛОВА.index(слово)] = 1
return рядок
модель_без_згладжування = MultinomialNB(alpha=1e-10)
модель_без_згладжування.fit(слова_в_оголошеннях, шахрайство)
бібліотечна = модель_без_згладжування.predict_proba(текст_у_рядок(оголошення))[0, 1]
print("наш розрахунок:", round(float(ймовірність_шахрайства), 8))
print("MultinomialNB: ", round(float(бібліотечна), 8))
assert np.isclose(ймовірність_шахрайства, бібліотечна), "розрахунок розійшовся!"
print("✅ збігається")
наш розрахунок: 0.85814862 MultinomialNB: 0.85814862 ✅ збігається
6. Що ламається без згладжування Лапласа¶
Слово «чек» у шахрайських оголошеннях трапилось нуль разів. Візьмемо оголошення «Терміново, лише передоплата, дешево, є чек»: три перших слова кричать «шахрайство», але один нульовий множник з'їдає їх усі.
оголошення_з_чеком = ["терміново", "передоплата", "дешево", "чек"]
def порахувати_оцінки(текст, alpha):
'''Дві оцінки для тексту при заданому згладжуванні. alpha=0 означає «без згладжування».'''
розмір_словника = len(СЛОВА)
ймов_шахр = (лічильники_шахр + alpha) / (усього_слів_шахр + alpha * розмір_словника)
ймов_чесн = (лічильники_чесн + alpha) / (усього_слів_чесн + alpha * розмір_словника)
оцінка_ш = апріорна_шахрайство
оцінка_ч = апріорна_чесність
for слово in текст:
номер = СЛОВА.index(слово)
оцінка_ш = оцінка_ш * ймов_шахр[номер]
оцінка_ч = оцінка_ч * ймов_чесн[номер]
return оцінка_ш, оцінка_ч
оцінка_ш, оцінка_ч = порахувати_оцінки(оголошення_з_чеком, alpha=0)
print("без згладжування:")
print(" оцінка за шахрайство:", оцінка_ш)
print(" оцінка за чесність: ", оцінка_ч)
print(" P(шахрайство) =", оцінка_ш / (оцінка_ш + оцінка_ч))
print(" ← модель абсолютно впевнена, що оголошення чесне, через одне слово")
без згладжування: оцінка за шахрайство: 0.0 оцінка за чесність: 3.083405671296296e-05 P(шахрайство) = 0.0 ← модель абсолютно впевнена, що оголошення чесне, через одне слово
# як згладжування витягує ситуацію
for alpha in [0, 0.1, 0.5, 1.0, 2.0]:
оцінка_ш, оцінка_ч = порахувати_оцінки(оголошення_з_чеком, alpha)
ймовірність = оцінка_ш / (оцінка_ш + оцінка_ч)
ймов_чека = (лічильники_шахр[СЛОВА.index("чек")] + alpha) / (усього_слів_шахр + alpha * 7)
print(f"alpha = {alpha:<4} P(чек|шахр) = {ймов_чека:.6f} P(шахрайство|текст) = {ймовірність:.4f}")
alpha = 0 P(чек|шахр) = 0.000000 P(шахрайство|текст) = 0.0000 alpha = 0.1 P(чек|шахр) = 0.001339 P(шахрайство|текст) = 0.1564 alpha = 0.5 P(чек|шахр) = 0.006452 P(шахрайство|текст) = 0.4410 alpha = 1.0 P(чек|шахр) = 0.012346 P(шахрайство|текст) = 0.5665 alpha = 2.0 P(чек|шахр) = 0.022727 P(шахрайство|текст) = 0.6505
# і знову звіряємось із бібліотекою — цього разу зі стандартним alpha = 1
модель = MultinomialNB(alpha=1.0)
модель.fit(слова_в_оголошеннях, шахрайство)
оцінка_ш, оцінка_ч = порахувати_оцінки(оголошення_з_чеком, alpha=1.0)
наша = оцінка_ш / (оцінка_ш + оцінка_ч)
бібліотечна = модель.predict_proba(текст_у_рядок(оголошення_з_чеком))[0, 1]
print("наш розрахунок:", round(float(наша), 8))
print("MultinomialNB: ", round(float(бібліотечна), 8))
assert np.isclose(наша, бібліотечна), "розрахунок розійшовся!"
print("✅ збігається")
наш розрахунок: 0.56653097 MultinomialNB: 0.56653097 ✅ збігається
7. Чому обчислення ведуть у логарифмах¶
Наш текст мав чотири слова, і добуток вийшов близько 5·10⁻⁵. У справжньому текстовому
класифікаторі слів сотні, і добуток багатьох дрібних чисел просто не вміщається
в float64. Подивимось, на якому саме слові він провалюється в нуль.
добуток = 1.0
for скільки_слів in range(1, 400):
добуток = добуток * 0.01
if добуток == 0.0:
print(f"добуток ймовірностей 0.01 став точним нулем на {скільки_слів}-му слові")
break
print("найменше додатне число у float64:", np.nextafter(0.0, 1.0))
print("сума логарифмів після 300 слів: ", 300 * np.log(0.01))
добуток ймовірностей 0.01 став точним нулем на 162-му слові найменше додатне число у float64: 5e-324 сума логарифмів після 300 слів: -1381.5510557964274
Вихід — рахувати суму логарифмів замість добутку. Щоб повернутись у відсотки, від обох логарифмів віднімають більший, беруть експоненту й нормують. Цей прийом зветься log-sum-exp; зробимо його руками й переконаємось, що відповідь та сама.
def ймовірність_через_логарифми(текст, alpha=1.0):
'''Те саме, що порахувати_оцінки, але без жодного множення дрібних чисел.'''
розмір_словника = len(СЛОВА)
лог_шахр = np.log((лічильники_шахр + alpha) / (усього_слів_шахр + alpha * розмір_словника))
лог_чесн = np.log((лічильники_чесн + alpha) / (усього_слів_чесн + alpha * розмір_словника))
сума_шахр = np.log(апріорна_шахрайство)
сума_чесн = np.log(апріорна_чесність)
for слово in текст:
номер = СЛОВА.index(слово)
сума_шахр += лог_шахр[номер]
сума_чесн += лог_чесн[номер]
# віднімаємо більший логарифм, щоб експонента не переповнилась і не занулилась
більший = max(сума_шахр, сума_чесн)
вага_шахр = np.exp(сума_шахр - більший)
вага_чесн = np.exp(сума_чесн - більший)
return вага_шахр / (вага_шахр + вага_чесн)
через_логарифми = ймовірність_через_логарифми(оголошення_з_чеком, alpha=1.0)
print("через добуток: ", round(float(наша), 10))
print("через логарифми: ", round(float(через_логарифми), 10))
assert np.isclose(через_логарифми, наша), "log-sum-exp розійшовся з прямим добутком!"
print("✅ збігається")
через добуток: 0.56653097 через логарифми: 0.56653097 ✅ збігається
# довгий текст: прямий добуток здається, логарифми ні
довгий_текст = ["терміново", "передоплата", "дешево"] * 250 # 750 слів, як довгий лист
оцінка_ш, оцінка_ч = порахувати_оцінки(довгий_текст, alpha=1.0)
print("прямий добуток на 750 словах:")
print(" оцінка за шахрайство:", оцінка_ш)
print(" оцінка за чесність: ", оцінка_ч)
print(" → обидві провалились у нуль, порівнювати нічого")
print()
# та сама пара чисел у логарифмах — обидві цілком собі скінченні
розмір_словника = len(СЛОВА)
лог_шахр = np.log((лічильники_шахр + 1) / (усього_слів_шахр + розмір_словника))
лог_чесн = np.log((лічильники_чесн + 1) / (усього_слів_чесн + розмір_словника))
сума_шахр = np.log(апріорна_шахрайство) + sum(лог_шахр[СЛОВА.index(с)] for с in довгий_текст)
сума_чесн = np.log(апріорна_чесність) + sum(лог_чесн[СЛОВА.index(с)] for с in довгий_текст)
print(f"у логарифмах: шахр = {сума_шахр:.1f}, чесн = {сума_чесн:.1f}")
print(f"різниця {сума_шахр - сума_чесн:.1f} — вирок «шахрайство», і жодного переповнення")
print("P(шахрайство) =", ймовірність_через_логарифми(довгий_текст, alpha=1.0))
прямий добуток на 750 словах: оцінка за шахрайство: 0.0 оцінка за чесність: 0.0 → обидві провалились у нуль, порівнювати нічого у логарифмах: шахр = -1067.1, чесн = -2079.9 різниця 1012.8 — вирок «шахрайство», і жодного переповнення P(шахрайство) = 1.0
8. Числові ознаки: гаусів Баєс проти kNN¶
Тепер інші дані — ті самі дві ознаки, що в темі 18:
відносна_ціна (запитувана ціна, поділена на типову для цієї моделі) і вік_акаунта
в днях. Шахрайство тут двох ґатунків: дешеві приманки зі свіжих акаунтів і вужчий
згусток «преміум»-приманок — майже ринкова ціна, зате акаунту менш як два місяці.
Плюс 6% перевернутих міток, бо в житті розмітка не буває ідеальною.
генератор = np.random.default_rng(42)
скільки_оголошень = 600
відносна_ціна = генератор.uniform(0.30, 1.45, скільки_оголошень)
вік_акаунта = генератор.uniform(0, 365, скільки_оголошень)
дешева_приманка = (відносна_ціна < 0.85) & (вік_акаунта < 150)
преміум_приманка = (відносна_ціна >= 0.95) & (відносна_ціна <= 1.28) & (вік_акаунта < 48)
мітка = (дешева_приманка | преміум_приманка).astype(int)
# 6% міток перевертаємо: без шуму задача була б надто чистою
перевернути = генератор.random(скільки_оголошень) < 0.06
мітка = np.where(перевернути, 1 - мітка, мітка)
ознаки = np.column_stack([відносна_ціна, вік_акаунта])
print("оголошень:", скільки_оголошень, "· шахрайських:", int(мітка.sum()),
f"({мітка.mean():.1%})")
оголошень: 600 · шахрайських: 160 (26.7%)
from sklearn.model_selection import train_test_split
ознаки_навч, ознаки_тест, мітки_навч, мітки_тест = train_test_split(
ознаки, мітка, test_size=0.4, random_state=0, stratify=мітка)
print("навчальних:", len(ознаки_навч), "· тестових:", len(ознаки_тест))
print("частка шахрайських у тесті:", round(float(мітки_тест.mean()), 4))
навчальних: 360 · тестових: 240 частка шахрайських у тесті: 0.2667
Гаусів Баєс зберігає на клас і ознаку лише два числа — середнє й розкид. Подивимось, що саме він вивчив.
from sklearn.naive_bayes import GaussianNB
гаусів = GaussianNB()
гаусів.fit(ознаки_навч, мітки_навч)
for клас, назва in [(0, "чесні"), (1, "шахрайські")]:
середні = гаусів.theta_[клас]
розкид = np.sqrt(гаусів.var_[клас])
print(f"{назва:<12} ціна {середні[0]:.3f} ± {розкид[0]:.3f} "
f"вік {середні[1]:.1f} ± {розкид[1]:.1f}")
print("апріорні ймовірності:", np.round(гаусів.class_prior_, 4))
print("усього чисел у моделі:", гаусів.theta_.size + гаусів.var_.size + гаусів.class_prior_.size)
чесні ціна 0.905 ± 0.343 вік 220.0 ± 94.1 шахрайські ціна 0.724 ± 0.288 вік 87.9 ± 77.2 апріорні ймовірності: [0.7333 0.2667] усього чисел у моделі: 10
Десять чисел — і це вся модель. Тепер порахуємо те саме руками. Ймовірність числової ознаки беремо з нормального розподілу з цими середнім і розкидом, і одразу в логарифмах.
def гаусів_вручну(рядки_ознак):
'''Наш власний GaussianNB: логарифм щільності нормального розподілу по кожній ознаці.'''
логарифми_класів = []
for клас in [0, 1]:
середні = гаусів.theta_[клас]
дисперсії = гаусів.var_[клас]
# log щільності нормального розподілу, складений по всіх ознаках
лог_ознак = -0.5 * np.log(2 * np.pi * дисперсії) - (рядки_ознак - середні) ** 2 / (2 * дисперсії)
логарифми_класів.append(np.log(гаусів.class_prior_[клас]) + лог_ознак.sum(axis=1))
логарифми_класів = np.array(логарифми_класів).T
# той самий log-sum-exp, що й для слів
більший = логарифми_класів.max(axis=1, keepdims=True)
ваги = np.exp(логарифми_класів - більший)
return ваги / ваги.sum(axis=1, keepdims=True)
наші_ймовірності = гаусів_вручну(ознаки_тест)
бібліотечні_ймовірності = гаусів.predict_proba(ознаки_тест)
print("перші три тестові оголошення, наш розрахунок:")
print(np.round(наші_ймовірності[:3], 6))
print("те саме з sklearn:")
print(np.round(бібліотечні_ймовірності[:3], 6))
assert np.allclose(наші_ймовірності, бібліотечні_ймовірності), "розрахунок розійшовся!"
print("✅ збігається на всіх", len(ознаки_тест), "тестових оголошеннях")
перші три тестові оголошення, наш розрахунок: [[0.7731 0.2269 ] [0.995045 0.004955] [0.9936 0.0064 ]] те саме з sklearn: [[0.7731 0.2269 ] [0.995045 0.004955] [0.9936 0.0064 ]] ✅ збігається на всіх 240 тестових оголошеннях
І головне порівняння теми: гаусів Баєс проти kNN із теми 18 на тих самих даних.
kNN беремо в конвеєрі зі StandardScaler — без масштабування він там не працює.
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
сусіди = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=9))
сусіди.fit(ознаки_навч, мітки_навч)
точність_баєса = гаусів.score(ознаки_тест, мітки_тест)
точність_сусідів = сусіди.score(ознаки_тест, мітки_тест)
точність_нічого_не_робити = 1 - мітки_тест.mean()
print(f"GaussianNB {точність_баєса:.4f}")
print(f"kNN (k = 9) {точність_сусідів:.4f}")
print(f"«усі оголошення чесні» {точність_нічого_не_робити:.4f}")
GaussianNB 0.8708 kNN (k = 9) 0.9167 «усі оголошення чесні» 0.7333
Гаусів Баєс помітно кращий за «нічого не робити», але програє kNN. Причина не в теоремі Баєса, а в припущенні про форму: гаусів різновид малює в класі рівно один дзвін на ознаку. Подивимось на гістограму відносної ціни всередині шахрайського класу.
import matplotlib.pyplot as plt
ціна_шахрайських = ознаки_навч[мітки_навч == 1, 0]
ціна_чесних = ознаки_навч[мітки_навч == 0, 0]
фігура, вісь = plt.subplots(figsize=(8, 3.6))
вісь.hist(ціна_чесних, bins=24, alpha=0.55, label="чесні")
вісь.hist(ціна_шахрайських, bins=24, alpha=0.75, label="шахрайські")
# крива, яку гаусів Баєс намалював для шахрайського класу
сітка_цін = np.linspace(0.30, 1.45, 300)
середнє = гаусів.theta_[1, 0]
розкид = np.sqrt(гаусів.var_[1, 0])
щільність = np.exp(-(сітка_цін - середнє) ** 2 / (2 * розкид ** 2)) / (розкид * np.sqrt(2 * np.pi))
# масштабуємо щільність під висоту гістограми, щоб форми можна було порівняти очима
висота, _ = np.histogram(ціна_шахрайських, bins=24)
вісь.plot(сітка_цін, щільність * висота.max() / щільність.max(),
linewidth=2, label="дзвін, який припускає GaussianNB")
вісь.set_xlabel("відносна ціна")
вісь.set_ylabel("кількість оголошень")
вісь.legend()
фігура.tight_layout()
plt.show()
print("два згустки шахрайства по осі ціни, а модель має право лише на один дзвін")
два згустки шахрайства по осі ціни, а модель має право лише на один дзвін
Ось і відповідь. Дешеві приманки дають один горб, «преміум»-приманки — другий, а гаусів Баєс розтягує між ними єдиний дзвін і неминуче мажe. kNN нічого не припускає про форму й тому обводить обидві області окремо.
Це не поразка методу, а точний портрет його припущення: наївний Баєс завжди швидший і компактніший, а програє рівно там, де його припущення розходиться з даними.
Завдання¶
🟢 Рівень 1¶
Візьми з архіву розділу 1 оголошення «Гарантія, є чек, торг» і порахуй для нього
дві оцінки при alpha = 1. Вирок передбач до обчислення, а потім перевір.
Зроблено, якщо твоє число збігається з MultinomialNB(alpha=1) за np.isclose,
і ти одним реченням пояснив, чому саме це оголошення виходить настільки чесним.
🟡 Рівень 2¶
Побудуй графік P(шахрайство | текст) для оголошення ["терміново", "передоплата", "дешево", "чек"] при alpha від 0 до 5 (крок 0.05). Познач на ньому лінію 50%.
Зроблено, якщо на графіку видно, що крива стартує з нуля, перетинає 50% і далі
виположується; у тексті названо, при якому приблизно alpha відбувається перетин
і що станеться з кривою, якщо alpha зробити дуже великим.
🔴 Рівень 3¶
Напиши власний клас із методами навчити(X, y) і передбачити_ймовірності(X), який
повторює MultinomialNB — зі згладжуванням і повністю в логарифмах, без жодного
множення ймовірностей.
Зроблено, якщо проходить перевірка
np.allclose(твої_ймовірності, MultinomialNB(alpha=1).fit(X, y).predict_proba(X))
на всіх ста оголошеннях архіву, і твій код не містить жодного множення двох
ймовірностей — лише додавання логарифмів.