Практика · Зворотне поширення помилки¶
📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.md
Лекція розібрала backprop на числах і склала таблицю ручного розрахунку. Тут ми зберемо ту саму мережу 2 → 2 → 1 на чистому NumPy і доведемо, що всі числа збігаються — до останнього знака, а не «на око».
Що зробимо:
- відтворимо ручний приклад із лекції: прямий прохід, втрата, усі девʼять градієнтів;
- перевіримо градієнти чисельно — центральною різницею, зі збігом до 1e-9;
- зробимо один крок спуску й побачимо, як падає втрата;
- зберемо дошку оголошень із теми 08, витягнемо дві ознаки й навчимо на них мережу;
- порівняємо результат із
MLPClassifierзіscikit-learn; - наприкінці навмисно зламаємо одну формулу backprop і подивимось, що ловить чисельна перевірка, а чого не видно ні з коду, ні з кривої втрат.
Про позначення. У лекції формули записані для одного обʼєкта-стовпчика:
z = Wa + b, матрицяWмає розмір «нейронів × входів». У коді обʼєкти зручніше тримати рядками таблиці, тому всі матриці транспоновані:Z = AW + b. Це та сама математика, записана навпаки — числа виходять ідентичні.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
генератор = np.random.default_rng(42)
np.set_printoptions(precision=4, suppress=True)
print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3
Частина 1 · Ручний приклад із лекції¶
1 · Оголошення, ваги, ціль¶
Беремо рівно те, що в таблиці 1 лекції: одне оголошення з дошки, дві стандартизовані ознаки й девʼять наперед заданих ваг.
x₁ = −1.00— вік акаунта на одне стандартне відхилення нижчий за середній;x₂ = −1.50— ціна на півтора відхилення нижча за типову для цієї моделі;y = 1— оголошення справді шахрайське.
# один обʼєкт як таблиця з одного рядка: далі той самий код працюватиме й на 800 рядках
ознаки_прикладу = np.array([[-1.00, -1.50]])
відповідь_прикладу = np.array([1.0])
# W1 має форму «входів × нейронів», бо обʼєкти в нас рядки (див. зауваження про позначення)
ваги_з_лекції = {
"W1": np.array([[-0.40, 0.50], # рядок = вага ознаки x₁ для нейронів h₁ і h₂
[-0.20, 0.40]]), # рядок = вага ознаки x₂ для нейронів h₁ і h₂
"b1": np.array([0.10, -0.10]),
"W2": np.array([[-1.10],
[0.70]]),
"b2": np.array([0.20]),
}
всього_параметрів = sum(значення.size for значення in ваги_з_лекції.values())
print("ознаки:", ознаки_прикладу[0], " ціль:", відповідь_прикладу[0])
print("параметрів у мережі:", всього_параметрів)
ознаки: [-1. -1.5] ціль: 1.0 параметрів у мережі: 9
2 · Прямий прохід¶
Дві дії на шар — зважена сума й активація:
Z₁ = XW₁ + b₁, A₁ = tanh(Z₁), Z₂ = A₁W₂ + b₂, ŷ = σ(Z₂).
Функція повертає усі проміжні значення, а не лише відповідь: без них зворотний прохід не обійдеться. Саме через це навчання зʼїдає більше памʼяті, ніж прогноз.
def сигмоїда(z):
"""σ(z) = 1/(1+e^-z). Аргумент обрізаємо, щоб експонента не переповнилась:
на результат це не впливає, бо σ(-40) уже нуль із точністю float."""
return 1 / (1 + np.exp(-np.clip(z, -40, 40)))
def прямий_прохід(ваги, X):
"""Повертає словник з усіма проміжними значеннями — вони знадобляться назад."""
Z1 = X @ ваги["W1"] + ваги["b1"]
A1 = np.tanh(Z1)
Z2 = A1 @ ваги["W2"] + ваги["b2"]
A2 = сигмоїда(Z2) # вихід читаємо як ймовірність шахрайства
return {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}
def крос_ентропія(ваги, X, y):
"""Середня втрата по вибірці. Одне число: наскільки погано зараз."""
прогноз = прямий_прохід(ваги, X)["A2"][:, 0]
# захист від log(0): при впевненій помилці прогноз може стати рівно 0 або 1
прогноз = np.clip(прогноз, 1e-12, 1 - 1e-12)
return float(-np.mean(y * np.log(прогноз) + (1 - y) * np.log(1 - прогноз)))
кеш = прямий_прохід(ваги_з_лекції, ознаки_прикладу)
втрата = крос_ентропія(ваги_з_лекції, ознаки_прикладу, відповідь_прикладу)
print("z⁽¹⁾ =", кеш["Z1"][0])
print("a⁽¹⁾ =", кеш["A1"][0])
print("z⁽²⁾ =", кеш["Z2"][0])
print("ŷ =", кеш["A2"][0])
print("L =", round(втрата, 4))
z⁽¹⁾ = [ 0.8 -1.2] a⁽¹⁾ = [ 0.664 -0.8337] z⁽²⁾ = [-1.114] ŷ = [0.2471] L = 1.3979
Звіримо з таблицею лекції. Це не формальність: якщо тут розійдеться хоч один знак, усе подальше буде брехнею.
# числа з таблиці 1 лекції, округлені до чотирьох знаків
assert np.allclose(кеш["Z1"][0], [0.8000, -1.2000], atol=1e-4)
assert np.allclose(кеш["A1"][0], [0.6640, -0.8337], atol=1e-4)
assert np.allclose(кеш["Z2"][0], [-1.1140], atol=1e-4)
assert np.allclose(кеш["A2"][0], [0.2471], atol=1e-4)
assert abs(втрата - 1.3979) < 1e-4
print("✅ прямий прохід збігається з таблицею лекції")
print(f"модель каже «шанс шахрайства {кеш['A2'][0, 0]:.1%}», правильна відповідь — 1")
print(f"для орієнтира: відповідь «не знаю» (0.5) дала б втрату {-np.log(0.5):.4f}")
✅ прямий прохід збігається з таблицею лекції модель каже «шанс шахрайства 24.7%», правильна відповідь — 1 для орієнтира: відповідь «не знаю» (0.5) дала б втрату 0.6931
3 · Зворотний прохід¶
Три формули з лекції, кожна — один рядок коду:
δ⁽²⁾ = ŷ − y— подарунок пари «сигмоїда + крос-ентропія»;δ⁽¹⁾ = (δ⁽²⁾W₂ᵀ) ⊙ (1 − A₁²)— провина протягується назад крізь ті самі ваги й гасне на похіднійtanh;∂L/∂W = Aᵀδ— «наскільки я винен» × «що прийшло по ребру».
Похідну tanh рахуємо не з Z1, а з готового A1: tanh′(z) = 1 − tanh²(z).
def зворотний_прохід(ваги, X, y):
"""Градієнт крос-ентропії по всіх параметрах за один прохід назад."""
кеш = прямий_прохід(ваги, X)
кількість = len(y)
# δ останнього шару. Ділимо на кількість обʼєктів одразу, бо втрата — це середнє
дельта2 = (кеш["A2"] - y[:, None]) / кількість
градієнти = {
"W2": кеш["A1"].T @ дельта2, # «хто винен» × «що прийшло по ребру»
"b2": дельта2.sum(axis=0), # у зсува вхід завжди одиниця, тому просто сума
}
# протягуємо провину назад крізь ваги другого шару...
дельта1 = дельта2 @ ваги["W2"].T
# ...і множимо на локальну похідну tanh — саме тут градієнт може затухнути
дельта1 = дельта1 * (1 - кеш["A1"] ** 2)
градієнти["W1"] = X.T @ дельта1
градієнти["b1"] = дельта1.sum(axis=0)
return градієнти
аналітичні = зворотний_прохід(ваги_з_лекції, ознаки_прикладу, відповідь_прикладу)
print("∂L/∂W⁽¹⁾ =\n", аналітичні["W1"])
print("∂L/∂b⁽¹⁾ =", аналітичні["b1"])
print("∂L/∂W⁽²⁾ =", аналітичні["W2"][:, 0])
print("∂L/∂b⁽²⁾ =", аналітичні["b2"])
∂L/∂W⁽¹⁾ = [[-0.463 0.1607] [-0.6945 0.2411]] ∂L/∂b⁽¹⁾ = [ 0.463 -0.1607] ∂L/∂W⁽²⁾ = [-0.4999 0.6276] ∂L/∂b⁽²⁾ = [-0.7529]
Ті самі девʼять чисел стоять у нижній половині таблиці лекції. Перевіряємо кожне.
# останні девʼять рядків таблиці 1 лекції
assert np.allclose(аналітичні["W1"], [[-0.4630, 0.1607],
[-0.6945, 0.2411]], atol=1e-4)
assert np.allclose(аналітичні["b1"], [0.4630, -0.1607], atol=1e-4)
assert np.allclose(аналітичні["W2"][:, 0], [-0.4999, 0.6276], atol=1e-4)
assert np.allclose(аналітичні["b2"], [-0.7529], atol=1e-4)
print("✅ усі девʼять градієнтів збігаються з таблицею лекції")
✅ усі девʼять градієнтів збігаються з таблицею лекції
4 · ⭐ Чисельна перевірка градієнта¶
Ось найцінніше, що є в темі. Формули backprop легко написати з помилкою: забув транспонування, переплутав знак, помножив не на ту похідну — і мережа все одно навчиться, просто гірше. Виняток не впаде, лосс буде спадати, а знайти таку помилку очима майже неможливо.
Тому є залізний спосіб перевірки — похідна за визначенням, центральною різницею:
∂L/∂w ≈ [L(w + h) − L(w − h)] / 2h
Похибка центральної різниці спадає як h², а не як h, тому вона точніша за односторонню.
Спосіб повільний — два прогони мережі на кожен параметр, — тому в навчанні його не
використовують. Але один раз, після написання backprop, на крихітній мережі — обовʼязково.
def чисельний_градієнт(ваги, X, y, крок=1e-5):
"""Похідна кожного параметра окремо. Повільно, зате незалежно від backprop."""
результат = {}
for назва in ваги:
похідні = np.zeros_like(ваги[назва])
# проходимо по кожному числу в матриці окремо
for позиція in np.ndindex(ваги[назва].shape):
початкове = ваги[назва][позиція]
ваги[назва][позиція] = початкове + крок
втрата_плюс = крос_ентропія(ваги, X, y)
ваги[назва][позиція] = початкове - крок
втрата_мінус = крос_ентропія(ваги, X, y)
ваги[назва][позиція] = початкове # обовʼязково повертаємо як було
похідні[позиція] = (втрата_плюс - втрата_мінус) / (2 * крок)
результат[назва] = похідні
return результат
чисельні = чисельний_градієнт(ваги_з_лекції, ознаки_прикладу, відповідь_прикладу)
print("параметр backprop чисельно різниця")
for назва in ["W1", "b1", "W2", "b2"]:
для_друку = zip(аналітичні[назва].ravel(), чисельні[назва].ravel())
for аналітично, чисельно in для_друку:
print(f" {назва:4s} {аналітично: .8f} {чисельно: .8f} {abs(аналітично - чисельно):.2e}")
параметр backprop чисельно різниця W1 -0.46298781 -0.46298781 1.05e-11 W1 0.16074910 0.16074910 1.00e-11 W1 -0.69448171 -0.69448171 6.45e-12 W1 0.24112365 0.24112365 2.06e-11 b1 0.46298781 0.46298781 6.00e-13 b1 -0.16074910 -0.16074910 1.00e-11 W2 -0.49993591 -0.49993591 4.31e-12 W2 0.62763674 0.62763674 1.12e-11 b2 -0.75287384 -0.75287384 2.30e-12
def в_один_вектор(словник):
"""Складає всі параметри в один довгий вектор — так зручніше рахувати похибку."""
частини = [словник[назва].ravel() for назва in ["W1", "b1", "W2", "b2"]]
return np.concatenate(частини)
вектор_backprop = в_один_вектор(аналітичні)
вектор_чисельний = в_один_вектор(чисельні)
# стандартна метрика перевірки: відносна різниця. Ділимо на суму норм,
# щоб число не залежало від масштабу самого градієнта
відносна_похибка = (np.linalg.norm(вектор_backprop - вектор_чисельний)
/ (np.linalg.norm(вектор_backprop) + np.linalg.norm(вектор_чисельний)))
print("перевірено параметрів:", len(вектор_backprop))
print(f"максимальна абсолютна різниця: {np.max(np.abs(вектор_backprop - вектор_чисельний)):.2e}")
print(f"відносна похибка: {відносна_похибка:.2e}")
print()
print("Орієнтир: < 1e-7 — усе правильно; 1e-5…1e-3 — підозріло; > 1e-2 — точно баг.")
assert np.allclose(вектор_backprop, вектор_чисельний, atol=1e-9), "backprop розійшовся з чисельною похідною!"
print("\n✅ усі девʼять похідних збіглися з точністю до 1e-9")
перевірено параметрів: 9 максимальна абсолютна різниця: 2.06e-11 відносна похибка: 1.02e-11 Орієнтир: < 1e-7 — усе правильно; 1e-5…1e-3 — підозріло; > 1e-2 — точно баг. ✅ усі девʼять похідних збіглися з точністю до 1e-9
Чому крок h не можна брати «якомога меншим»¶
В інтерактиві 3 лекції це видно на графіку, а тут — на числах.
Завеликий h дає похибку відсікання: січна проходить не там, де дотична. Замалий —
похибку округлення: L(w+h) і L(w−h) відрізняються в пʼятнадцятому знаку, а
float64 стільки знаків не тримає.
print("крок h відносна похибка")
for крок in [1e-1, 1e-3, 1e-5, 1e-7, 1e-9, 1e-11, 1e-14]:
проба = чисельний_градієнт(ваги_з_лекції, ознаки_прикладу, відповідь_прикладу, крок=крок)
вектор_проби = в_один_вектор(проба)
похибка = (np.linalg.norm(вектор_backprop - вектор_проби)
/ (np.linalg.norm(вектор_backprop) + np.linalg.norm(вектор_проби)))
print(f" {крок:.0e} {похибка:.2e}")
print("\nНайкраще — десь посередині. Робочий орієнтир: h від 1e-4 до 1e-6.")
крок h відносна похибка 1e-01 6.23e-04 1e-03 6.27e-08 1e-05 1.02e-11 1e-07 1.21e-09 1e-09 1.21e-07 1e-11 8.43e-06 1e-14 8.76e-03 Найкраще — десь посередині. Робочий орієнтир: h від 1e-4 до 1e-6.
5 · Один крок градієнтного спуску¶
Градієнт порахований — далі працює вже не backprop, а градієнтний спуск. Це різні речі, і плутати їх не варто: backprop лише постачає числа, крок робить оптимізатор.
крок_навчання = 0.5
ваги_після_кроку = {назва: ваги_з_лекції[назва] - крок_навчання * аналітичні[назва]
for назва in ваги_з_лекції}
прогноз_до = прямий_прохід(ваги_з_лекції, ознаки_прикладу)["A2"][0, 0]
прогноз_після = прямий_прохід(ваги_після_кроку, ознаки_прикладу)["A2"][0, 0]
втрата_після = крос_ентропія(ваги_після_кроку, ознаки_прикладу, відповідь_прикладу)
print(f"прогноз ŷ: {прогноз_до:.4f} → {прогноз_після:.4f}")
print(f"втрата L: {втрата:.4f} → {втрата_після:.4f}")
assert abs(прогноз_після - 0.6136) < 1e-4
assert abs(втрата_після - 0.4884) < 1e-4
print("\n✅ збігається з останнім кроком інтерактиву 1: один крок — і модель на правильному боці")
прогноз ŷ: 0.2471 → 0.6136 втрата L: 1.3979 → 0.4884 ✅ збігається з останнім кроком інтерактиву 1: один крок — і модель на правильному боці
Частина 2 · Дошка оголошень¶
Одного обʼєкта досить, щоб перевірити формули, але не досить, щоб чогось навчитись. Зберемо ту саму дошку оголошень про вживані телефони, що й у темі про розвідку даних, і навчимо на ній нашу мережу.
6 · Будуємо дані¶
кількість_оголошень = 1200
моделі = ["Alfa A5", "Alfa A7", "Beta 12", "Beta 12 Pro", "Gamma X", "Gamma X Ultra"]
ціна_нового = {"Alfa A5": 5200, "Alfa A7": 7400, "Beta 12": 12000,
"Beta 12 Pro": 17500, "Gamma X": 24000, "Gamma X Ultra": 34000}
модель = генератор.choice(моделі, size=кількість_оголошень, p=[0.24, 0.22, 0.18, 0.16, 0.12, 0.08])
рік = генератор.integers(2017, 2025, size=кількість_оголошень)
стан = генератор.choice(["нове", "дуже добре", "добре", "задовільне"],
size=кількість_оголошень, p=[0.08, 0.32, 0.42, 0.18])
памʼять = генератор.choice([64, 128, 256, 512], size=кількість_оголошень, p=[0.30, 0.38, 0.24, 0.08])
# більшість продавців мають свіжі акаунти, старих усе менше — звідси експоненційний розподіл
вік_акаунта = np.round(генератор.exponential(420, size=кількість_оголошень) + 3).astype(int)
print("оголошень:", кількість_оголошень)
print("моделі перших пʼятьох:", модель[:5])
print("вік акаунта перших пʼятьох:", вік_акаунта[:5])
оголошень: 1200 моделі перших пʼятьох: ['Beta 12 Pro' 'Alfa A7' 'Gamma X' 'Beta 12 Pro' 'Alfa A5'] вік акаунта перших пʼятьох: [186 394 44 777 351]
# «типова» ціна — скільки телефон коштує за паспортом: нова ціна мінус знос,
# помножена на коефіцієнти стану й памʼяті
коефіцієнт_стану = np.array(
[{"нове": 1.0, "дуже добре": 0.88, "добре": 0.75, "задовільне": 0.58}[s] for s in стан])
коефіцієнт_памʼяті = np.array(
[{64: 0.85, 128: 1.0, 256: 1.15, 512: 1.32}[m] for m in памʼять])
типова_ціна = (np.array([ціна_нового[m] for m in модель])
* 0.82 ** (2024 - рік) # телефон дешевшає приблизно на 18 % за рік
* коефіцієнт_стану * коефіцієнт_памʼяті)
ціна = типова_ціна * генератор.lognormal(0, 0.13, size=кількість_оголошень)
print("типова ціна перших пʼятьох:", типова_ціна[:5].round(0))
print("ціна в оголошенні :", ціна[:5].round(0))
типова ціна перших пʼятьох: [ 3763. 3060. 12071. 8322. 1499.] ціна в оголошенні : [ 3085. 2810. 10822. 8346. 1852.]
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 0.10 + 0.30 * np.exp(-вік_акаунта / 120)
шахрайське = генератор.random(кількість_оголошень) < шанс_шахрайства
# три чверті шахраїв ставлять різко занижену ціну («неймовірна знижка»),
# решта — завищену, у розрахунку на велику передоплату
ставить_дешево = генератор.random(кількість_оголошень) < 0.74
дешева_приманка = шахрайське & ставить_дешево
дорога_приманка = шахрайське & ~ставить_дешево
ціна[дешева_приманка] = типова_ціна[дешева_приманка] * генератор.uniform(0.20, 0.45, дешева_приманка.sum())
ціна[дорога_приманка] = типова_ціна[дорога_приманка] * генератор.uniform(2.6, 3.8, дорога_приманка.sum())
ціна = np.round(ціна, -1) # ціни на дошці круглі, до десятків
дошка = pd.DataFrame({"модель": модель, "вік_акаунта": вік_акаунта,
"ціна": ціна, "шахрайське": шахрайське.astype(int)})
print("шахрайських оголошень:", int(дошка["шахрайське"].sum()), "з", len(дошка))
print("занижена ціна:", int(дешева_приманка.sum()), "· завищена:", int(дорога_приманка.sum()))
дошка.head()
шахрайських оголошень: 196 з 1200 занижена ціна: 145 · завищена: 51
| модель | вік_акаунта | ціна | шахрайське | |
|---|---|---|---|---|
| 0 | Beta 12 Pro | 186 | 3090.0 | 0 |
| 1 | Alfa A7 | 394 | 2810.0 | 0 |
| 2 | Gamma X | 44 | 33060.0 | 1 |
| 3 | Beta 12 Pro | 777 | 8350.0 | 0 |
| 4 | Alfa A5 | 351 | 1850.0 | 0 |
7 · Дві ознаки¶
Мережа з лекції має рівно два входи, тож нам потрібні дві ознаки — і саме ті, про які йшлося: вік акаунта й відхилення ціни від типової.
«Типової ціни» в реальних даних ніхто не знає, її треба відновити з самої дошки. Найпростіший чесний спосіб — медіана по моделі: медіана стійка до викидів, тому шахрайські ціни її майже не зсувають.
Обидві ознаки беремо в логарифмі. Причина проста: ціна вдвічі нижча за типову і ціна вдвічі вища — це однакове за силою відхилення, і логарифм робить їх симетричними навколо нуля. Те саме з віком акаунта: різниця між 3 і 30 днями важливіша, ніж між 900 і 927.
медіана_по_моделі = дошка.groupby("модель")["ціна"].transform("median")
дошка["лог_віку"] = np.log(дошка["вік_акаунта"])
дошка["лог_відхилення_ціни"] = np.log(дошка["ціна"] / медіана_по_моделі)
сирі_ознаки = дошка[["лог_віку", "лог_відхилення_ціни"]].to_numpy()
# стандартизація: віднімаємо середнє, ділимо на стандартне відхилення.
# Без неї одна ознака перекрила б другу просто через масштаб
середні = сирі_ознаки.mean(axis=0)
відхилення = сирі_ознаки.std(axis=0)
ознаки = (сирі_ознаки - середні) / відхилення
мітки = дошка["шахрайське"].to_numpy().astype(float)
print("форма таблиці ознак:", ознаки.shape)
print("середні після стандартизації:", ознаки.mean(axis=0).round(6))
print("відхилення після стандартизації:", ознаки.std(axis=0).round(6))
print(f"частка шахрайських: {мітки.mean():.3f}")
форма таблиці ознак: (1200, 2) середні після стандартизації: [-0. -0.] відхилення після стандартизації: [1. 1.] частка шахрайських: 0.163
X_навч, X_тест, y_навч, y_тест = train_test_split(
ознаки, мітки, test_size=0.3, random_state=0, stratify=мітки)
print(f"навчальна вибірка: {len(y_навч)} оголошень, шахрайських {y_навч.mean():.3f}")
print(f"тестова вибірка: {len(y_тест)} оголошень, шахрайських {y_тест.mean():.3f}")
print(f"базова точність «усі чесні»: {1 - y_тест.mean():.4f}")
навчальна вибірка: 840 оголошень, шахрайських 0.163 тестова вибірка: 360 оголошень, шахрайських 0.164 базова точність «усі чесні»: 0.8361
fig, ax = plt.subplots(figsize=(6, 5))
ax.scatter(X_навч[y_навч == 0, 0], X_навч[y_навч == 0, 1], s=14, color="teal", alpha=.5, label="чесне")
ax.scatter(X_навч[y_навч == 1, 0], X_навч[y_навч == 1, 1], s=18, color="crimson", alpha=.7, label="шахрайське")
ax.set_xlabel("вік акаунта (лог, стандартизовано)")
ax.set_ylabel("відхилення ціни (лог, стандартизовано)")
ax.set_title("Дошка оголошень у двох ознаках")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
print("Шахрайські оголошення сидять двома смугами — знизу (занижена ціна) і зверху (завищена).")
print("Жодна пряма їх не відділить: потрібна саме нелінійна межа.")
Шахрайські оголошення сидять двома смугами — знизу (занижена ціна) і зверху (завищена). Жодна пряма їх не відділить: потрібна саме нелінійна межа.
8 · Навчання¶
Той самий зворотний_прохід, який ми щойно перевірили чисельно, тепер працює на 840
оголошеннях замість одного. Жодного рядка міняти не довелось: формули однакові для
будь-якої кількості обʼєктів.
def створити_мережу(прихованих, генератор_ваг):
"""Ваги випадкові, зсуви нульові. Нулями ваги ініціалізувати не можна:
тоді всі нейрони шару лишились би однаковими назавжди."""
return {
"W1": генератор_ваг.normal(0, 1, (2, прихованих)) / np.sqrt(2),
"b1": np.zeros(прихованих),
"W2": генератор_ваг.normal(0, 1, (прихованих, 1)) / np.sqrt(прихованих),
"b2": np.zeros(1),
}
def навчити(ваги, X, y, епох=3000, крок=0.5, зламаний_градієнт=False):
"""Повний градієнтний спуск. Повертає навчені ваги й історію втрати."""
ваги = {назва: значення.copy() for назва, значення in ваги.items()}
історія = []
for епоха in range(епох):
if зламаний_градієнт:
градієнти = зворотний_прохід_із_помилкою(ваги, X, y)
else:
градієнти = зворотний_прохід(ваги, X, y)
for назва in ваги:
ваги[назва] = ваги[назва] - крок * градієнти[назва]
історія.append(крос_ентропія(ваги, X, y))
return ваги, історія
def точність(ваги, X, y):
прогноз = прямий_прохід(ваги, X)["A2"][:, 0]
return float(((прогноз > 0.5) == (y > 0.5)).mean())
мережа = створити_мережу(2, np.random.default_rng(42))
навчена, історія = навчити(мережа, X_навч, y_навч)
for епоха in [0, 100, 500, 1000, 2999]:
print(f"епоха {епоха:>4}: втрата {історія[епоха]:.4f}")
print(f"\nточність на навчальній: {точність(навчена, X_навч, y_навч):.4f}")
print(f"точність на тестовій: {точність(навчена, X_тест, y_тест):.4f}")
print(f"базова «усі чесні»: {1 - y_тест.mean():.4f}")
епоха 0: втрата 0.5654 епоха 100: втрата 0.3383 епоха 500: втрата 0.3130 епоха 1000: втрата 0.3087 епоха 2999: втрата 0.3047 точність на навчальній: 0.9036 точність на тестовій: 0.9000 базова «усі чесні»: 0.8361
fig, (лівий, правий) = plt.subplots(1, 2, figsize=(11, 4.6))
лівий.plot(історія, color="crimson")
лівий.set_xlabel("епоха"); лівий.set_ylabel("крос-ентропія")
лівий.set_title("Втрата падає з кожним кроком"); лівий.grid(alpha=.25)
# межа рішень: проганяємо сітку точок крізь навчену мережу
сітка_x = np.linspace(X_навч[:, 0].min() - .5, X_навч[:, 0].max() + .5, 200)
сітка_y = np.linspace(X_навч[:, 1].min() - .5, X_навч[:, 1].max() + .5, 200)
СX, СY = np.meshgrid(сітка_x, сітка_y)
сітка = np.c_[СX.ravel(), СY.ravel()]
ймовірності = прямий_прохід(навчена, сітка)["A2"][:, 0].reshape(СX.shape)
правий.contourf(СX, СY, ймовірності, levels=20, cmap="RdBu_r", alpha=.6)
правий.contour(СX, СY, ймовірності, levels=[0.5], colors="black", linewidths=1.4)
правий.scatter(X_тест[y_тест == 0, 0], X_тест[y_тест == 0, 1], s=12, color="teal", alpha=.6)
правий.scatter(X_тест[y_тест == 1, 0], X_тест[y_тест == 1, 1], s=16, color="crimson", alpha=.8)
правий.set_xlabel("вік акаунта (лог, стандартизовано)")
правий.set_ylabel("відхилення ціни (лог, стандартизовано)")
правий.set_title("Межа рішень: чорна лінія — поріг 0.5")
plt.tight_layout(); plt.show()
print("Два прихованих нейрони вирізали смугу «звичайна ціна» — усе поза нею модель вважає підозрілим.")
Два прихованих нейрони вирізали смугу «звичайна ціна» — усе поза нею модель вважає підозрілим.
9 · Порівняння з бібліотекою¶
Найголовніша перевірка курсу: всередині scikit-learn немає магії. Беремо
MLPClassifier тієї ж форми, з тією ж активацією — і дивимось, чи вийде те саме.
Числа не збіжаться до знака (там інший оптимізатор і своя ініціалізація), але точність
має бути та сама.
бібліотечна = MLPClassifier(hidden_layer_sizes=(2,), activation="tanh",
max_iter=3000, random_state=42)
бібліотечна.fit(X_навч, y_навч)
наша_точність = точність(навчена, X_тест, y_тест)
бібліотечна_точність = бібліотечна.score(X_тест, y_тест)
print(f"наша мережа на NumPy: {наша_точність:.4f}")
print(f"MLPClassifier: {бібліотечна_точність:.4f}")
print(f"різниця: {abs(наша_точність - бібліотечна_точність):.4f}")
assert abs(наша_точність - бібліотечна_точність) < 0.03, "розрахунок розійшовся з бібліотекою!"
print("\n✅ збігається: усередині бібліотеки той самий backprop, який ми написали руками")
наша мережа на NumPy: 0.9000 MLPClassifier: 0.9028 різниця: 0.0028 ✅ збігається: усередині бібліотеки той самий backprop, який ми написали руками
Частина 3 · Навмисна помилка¶
Тепер найважливіше практичне вміння теми. Зламаємо backprop так, як його ламають найчастіше: забудемо помножити на похідну активації. Один зниклий множник.
def зворотний_прохід_із_помилкою(ваги, X, y):
"""Той самий код, але без множення на похідну tanh. Помилка в одному рядку."""
кеш = прямий_прохід(ваги, X)
кількість = len(y)
дельта2 = (кеш["A2"] - y[:, None]) / кількість
градієнти = {"W2": кеш["A1"].T @ дельта2, "b2": дельта2.sum(axis=0)}
дельта1 = дельта2 @ ваги["W2"].T
# ⚠️ ТУТ ПОМИЛКА: пропущено множення на (1 - A1²)
градієнти["W1"] = X.T @ дельта1
градієнти["b1"] = дельта1.sum(axis=0)
return градієнти
зламані = зворотний_прохід_із_помилкою(ваги_з_лекції, ознаки_прикладу, відповідь_прикладу)
print("параметр правильно зламано")
for назва in ["W1", "b1", "W2", "b2"]:
for правильно, зламано in zip(аналітичні[назва].ravel(), зламані[назва].ravel()):
позначка = " ← розійшлось" if abs(правильно - зламано) > 1e-9 else ""
print(f" {назва:4s} {правильно: .8f} {зламано: .8f}{позначка}")
параметр правильно зламано W1 -0.46298781 -0.82816122 ← розійшлось W1 0.16074910 0.52701169 ← розійшлось W1 -0.69448171 -1.24224183 ← розійшлось W1 0.24112365 0.79051753 ← розійшлось b1 0.46298781 0.82816122 ← розійшлось b1 -0.16074910 -0.52701169 ← розійшлось W2 -0.49993591 -0.49993591 W2 0.62763674 0.62763674 b2 -0.75287384 -0.75287384
Око бачить лише те, що якісь числа інші. Але які з них правильні? Без чисельної перевірки відповіді немає. З нею — є, і однозначна.
вектор_зламаний = в_один_вектор(зламані)
похибка_правильного = (np.linalg.norm(вектор_backprop - вектор_чисельний)
/ (np.linalg.norm(вектор_backprop) + np.linalg.norm(вектор_чисельний)))
похибка_зламаного = (np.linalg.norm(вектор_зламаний - вектор_чисельний)
/ (np.linalg.norm(вектор_зламаний) + np.linalg.norm(вектор_чисельний)))
print(f"відносна похибка правильного backprop: {похибка_правильного:.2e}")
print(f"відносна похибка зламаного backprop: {похибка_зламаного:.2e}")
print(f"різниця у {похибка_зламаного / похибка_правильного:.0e} разів")
assert похибка_правильного < 1e-7, "правильний градієнт мав пройти перевірку"
assert похибка_зламаного > 1e-2, "перевірка не помітила помилки — так не буває"
print("\n✅ чисельна перевірка спіймала помилку: 2.8e-01 проти 1.0e-11 — сплутати неможливо")
відносна похибка правильного backprop: 1.02e-11 відносна похибка зламаного backprop: 2.81e-01 різниця у 3e+10 разів ✅ чисельна перевірка спіймала помилку: 2.8e-01 проти 1.0e-11 — сплутати неможливо
10 · А тепер найнеприємніше¶
Зламана мережа все одно навчається. Виняток не падає, розміри матриць збігаються, втрата за першу сотню епох падає майже так само, як у правильної. Далі вона тихо повзе вгору — але цей натяк дуже легко списати на завелику швидкість навчання й піти шукати проблему не там. Без чисельної перевірки ти б витратив тиждень на архітектуру й гіперпараметри.
мережа_2 = створити_мережу(2, np.random.default_rng(42))
зламана, історія_зламаної = навчити(мережа_2, X_навч, y_навч, зламаний_градієнт=True)
print("епоха правильний backprop зламаний backprop")
for епоха in [0, 100, 500, 1000, 2999]:
print(f"{епоха:>5} {історія[епоха]:>17.4f} {історія_зламаної[епоха]:>17.4f}")
print()
print(f"точність, правильний backprop: {точність(навчена, X_тест, y_тест):.4f}")
print(f"точність, зламаний backprop: {точність(зламана, X_тест, y_тест):.4f}")
print(f"базова «усі чесні»: {1 - y_тест.mean():.4f}")
print()
print("Зламана мережа ледве обігнала базову відповідь «усі чесні». Її крива втрат після")
print("сотої епохи повзе вгору — але сама по собі така крива схожа на завелику швидкість навчання.")
епоха правильний backprop зламаний backprop
0 0.5654 0.5468
100 0.3383 0.3660
500 0.3130 0.3744
1000 0.3087 0.3788
2999 0.3047 0.3842
точність, правильний backprop: 0.9000
точність, зламаний backprop: 0.8528
базова «усі чесні»: 0.8361
Зламана мережа ледве обігнала базову відповідь «усі чесні». Її крива втрат після
сотої епохи повзе вгору — але сама по собі така крива схожа на завелику швидкість навчання.
fig, ax = plt.subplots(figsize=(7, 4.4))
ax.plot(історія, color="teal", label="правильний backprop")
ax.plot(історія_зламаної, color="crimson", label="зламаний backprop")
ax.set_xlabel("епоха"); ax.set_ylabel("крос-ентропія")
ax.set_title("Обидві криві виглядають правдоподібно — і саме тому помилку не видно")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
print("Мораль: криву втрат не можна використовувати як доказ правильності градієнта.")
Мораль: криву втрат не можна використовувати як доказ правильності градієнта.
Завдання¶
🟢 Рівень 1 — База¶
Поміняй у ручному прикладі ціль з y = 1 на y = 0 (тобто вдай, що оголошення чесне)
і перерахуй усе заново: прямий прохід, втрату, усі девʼять градієнтів.
Зроблено, якщо: втрата стала меншою за 0.3, знак δ⁽²⁾ змінився на протилежний,
а чисельна перевірка так само дала відносну похибку менше 1e-7.
🟡 Рівень 2 — Плюс¶
Замініть tanh у прихованому шарі на ReLU (np.maximum(0, z), похідна — (z > 0)).
Перепиши прямий_прохід і зворотний_прохід, прожени чисельну перевірку й навчи мережу
на дошці оголошень.
Зроблено, якщо: перевірка градієнта пройдена (похибка < 1e-6), а точність на тестовій
вибірці відрізняється від tanh-версії менш ніж на 3 відсоткові пункти. Напиши одним
реченням, чому для ReLU перевірка може дати гіршу похибку, ніж для tanh.
🔴 Рівень 3 — Виклик¶
Додай мережі другий прихований шар: 2 → 4 → 4 → 1. Перепиши прямий і зворотний проходи
так, щоб кількість шарів задавалась списком, а не була зашита в код. Прожени чисельну
перевірку на всіх параметрах.
Зроблено, якщо: перевірка проходить із відносною похибкою менше 1e-7, мережа
навчається, а ти можеш показати числами, у скільки разів норма градієнта першого шару
менша за норму градієнта останнього.