Практика 31 · Нейронні мережі¶
📖 Теорія: відкрий
lecture.htmlу цій же теці (блок 7, тема 31). 📝 Домашнє:homework.md· 🧪 Тест:quiz.html
Лекція розібрала мережу на формули. Тут ми зберемо її з нуля на чистому NumPy: прямий прохід, крос-ентропія, зворотне поширення — і навчання, яке справді зменшує втрату. А потім перевіримо два твердження лекції, у які легше не повірити, ніж повірити: що без активації глибина не існує і що результат залежить від зерна.
Що зробимо:
- Реалізуємо прямий прохід мережі 2 → H → 1 руками
- Порахуємо крос-ентропію й перевіримо чисельно, що $\partial L/\partial z = \hat y - y$
- Напишемо зворотне поширення — три формули, три рядки коду
- Навчимо мережу на задачі «два кола» й намалюємо межу рішень
- ⭐ Доведемо
assert-ом, що без нелінійності мережа лишається лінійною - Побачимо на числах, що результат залежить від початкових ваг
- Порівняємо свою мережу з
MLPClassifier
Де решта backprop. Виведення трьох формул крок за кроком, чисельна перевірка кожної похідної й дослід «зламай градієнт навмисно» живуть у практиці теми 32 — тут ми беремо готову функцію й одразу вчимо мережу.
Про позначення. У лекції формули записані для одного обʼєкта-стовпчика: $z = Wa + b$, матриця $W$ має розмір «нейронів × входів». У коді зручніше тримати обʼєкти рядками таблиці, тому всі матриці транспоновані: $Z = AW + b$. Це та сама математика, записана навпаки.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
# «Два кола» — той самий набір, що в інтерактиві 1 лекції.
# Внутрішнє коло — один клас, зовнішнє кільце — другий. Жодна пряма їх не розділить.
X_усі, y_усі = make_circles(n_samples=400, factor=0.45, noise=0.12, random_state=42)
X_навч, X_тест, y_навч, y_тест = train_test_split(
X_усі, y_усі, test_size=0.3, random_state=0, stratify=y_усі)
print(f"навчальна вибірка: {X_навч.shape[0]} точок × {X_навч.shape[1]} ознаки")
print(f"тестова вибірка: {X_тест.shape[0]} точок")
print(f"класи збалансовані: частка класу 1 = {y_навч.mean():.2f}")
навчальна вибірка: 280 точок × 2 ознаки тестова вибірка: 120 точок класи збалансовані: частка класу 1 = 0.50
fig, ax = plt.subplots(figsize=(5.5, 5.5))
ax.scatter(X_навч[y_навч == 0, 0], X_навч[y_навч == 0, 1], s=22, color="crimson", label="клас 0")
ax.scatter(X_навч[y_навч == 1, 0], X_навч[y_навч == 1, 1], s=22, color="teal", label="клас 1")
ax.set_title("Два кола: задача, у якій пряма безсила")
ax.set_xlabel("x₁"); ax.set_ylabel("x₂")
ax.legend(); ax.grid(alpha=.25); ax.set_aspect("equal")
plt.tight_layout(); plt.show()
1. Прямий прохід руками¶
Наша мережа: 2 входи → H прихованих нейронів (tanh) → 1 вихід (сигмоїда). Кожен шар робить рівно дві дії — зважену суму й активацію:
$$Z^{(1)} = A^{(0)} W^{(1)} + b^{(1)}, \quad A^{(1)} = \tanh(Z^{(1)})$$ $$Z^{(2)} = A^{(1)} W^{(2)} + b^{(2)}, \quad \hat y = \sigma(Z^{(2)})$$
Ваги ініціалізуємо не нулями (тоді всі нейрони шару лишились би однаковими назавжди) і не абияк, а за схемою, яка тримає дисперсію сигналу сталою з глибиною.
def сигмоїда(z):
"""σ(z) = 1/(1+e^-z). Для великих відʼємних z експонента переповнюється,
тому обрізаємо аргумент — на результат це не впливає, бо σ(-40) уже нуль."""
return 1 / (1 + np.exp(-np.clip(z, -40, 40)))
def створити_мережу(прихованих_нейронів, генератор):
"""Ваги — випадкові, зсуви — нулі. Це стандартна практика."""
# ділимо на корінь із кількості входів у нейрон (схема Glorot для tanh):
# так дисперсія сигналу не росте й не згасає при проходженні шару
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, активація="tanh"):
"""Повертає всі проміжні значення — вони знадобляться для зворотного проходу."""
Z1 = X @ ваги["W1"] + ваги["b1"]
A1 = np.tanh(Z1) if активація == "tanh" else Z1 # "linear" = без нелінійності
Z2 = A1 @ ваги["W2"] + ваги["b2"]
A2 = сигмоїда(Z2) # вихід читаємо як ймовірність класу 1
return {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}
демо_ваги = створити_мережу(4, np.random.default_rng(42))
print("розміри параметрів:")
for назва, значення in демо_ваги.items():
print(f" {назва}: {значення.shape}")
всього_параметрів = sum(значення.size for значення in демо_ваги.values())
print(f"\nусього чисел, які треба підібрати: {всього_параметрів}")
кеш = прямий_прохід(демо_ваги, X_навч)
print(f"\nвихід мережі для перших трьох точок: {кеш['A2'][:3, 0].round(4)}")
print("Мережа ще не вчилась — це просто випадкові числа біля 0.5.")
розміри параметрів: W1: (2, 4) b1: (4,) W2: (4, 1) b2: (1,) усього чисел, які треба підібрати: 17 вихід мережі для перших трьох точок: [0.5981 0.453 0.5888] Мережа ще не вчилась — це просто випадкові числа біля 0.5.
2. Крос-ентропія і чому її градієнт такий чистий¶
Функція втрат для двох класів:
$$L = -\frac{1}{n}\sum_i \left[ y_i \log \hat y_i + (1-y_i)\log(1-\hat y_i) \right]$$
Лекція вивела дивовижний результат: якщо на виході сигмоїда, то похідна втрати по передактиваційному значенню $z$ дорівнює просто різниці:
$$\frac{\partial L}{\partial z} = \hat y - y$$
Знаменник $\hat y(1-\hat y)$ з похідної крос-ентропії скорочується з тим самим множником із похідної сигмоїди. Перевіримо це чисельно на одному числі — не повіримо на слово.
def крос_ентропія(ваги, X, y, активація="tanh"):
"""Середня крос-ентропія по вибірці. Одне число: наскільки погано зараз."""
прогноз = прямий_прохід(ваги, 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 - прогноз)))
# беремо один-єдиний обʼєкт, щоб можна було все прослідкувати руками
z = 0.8
правильна_відповідь = 1.0
крок = 1e-6
def втрата_одного(z_значення):
прогноз = сигмоїда(z_значення)
return -(правильна_відповідь * np.log(прогноз)
+ (1 - правильна_відповідь) * np.log(1 - прогноз))
# центральна різниця: (L(z+h) - L(z-h)) / 2h — найточніша проста апроксимація похідної
чисельна_похідна = (втрата_одного(z + крок) - втрата_одного(z - крок)) / (2 * крок)
формула_з_лекції = сигмоїда(z) - правильна_відповідь
print(f"z = {z}, ŷ = σ(z) = {сигмоїда(z):.6f}, y = {правильна_відповідь}")
print(f"чисельна похідна ∂L/∂z : {чисельна_похідна:.10f}")
print(f"формула ŷ − y : {формула_з_лекції:.10f}")
print(f"різниця : {abs(чисельна_похідна - формула_з_лекції):.2e}")
assert np.allclose(чисельна_похідна, формула_з_лекції), "скорочення не спрацювало!"
print("\n✅ ∂L/∂z = ŷ − y — скорочення справжнє, а не риторична фігура")
z = 0.8, ŷ = σ(z) = 0.689974, y = 1.0 чисельна похідна ∂L/∂z : -0.3100255189 формула ŷ − y : -0.3100255189 різниця : 4.23e-12 ✅ ∂L/∂z = ŷ − y — скорочення справжнє, а не риторична фігура
Порівняй це з MSE на тому самому виході: там $\partial L/\partial z = (\hat y - y)\cdot\hat y(1-\hat y)$. Якщо модель упевнено помилилась ($\hat y = 0.999$ при $y = 0$), множник $\hat y(1-\hat y)$ дорівнює 0.001 — градієнт майже нульовий, і модель не хоче виправлятись. Крос-ентропія знімає цю проблему точним скороченням.
3. Зворотне поширення¶
Три формули з лекції, і кожна перетворюється на один рядок коду:
$$\delta^{(L)} = \hat y - y \quad\cdot\quad \delta^{(l)} = (\delta^{(l+1)} W^{(l+1)T}) \odot f'(z^{(l)}) \quad\cdot\quad \frac{\partial L}{\partial W^{(l)}} = (A^{(l-1)})^T \delta^{(l)}$$
Звідки береться кожна з них, чому провину рахують саме у зворотному порядку і як довести, що твоя реалізація правильна, — покроково розібрано в практиці теми 32. Тут ми беремо результат і пишемо функцію: далі без неї не навчити мережу.
Похідна tanh рахується з самого значення функції: $\tanh'(z) = 1 - \tanh^2(z)$.
Тому нам не потрібен Z1 — досить A1, яке вже порахував прямий прохід.
def зворотний_прохід(ваги, X, y, активація="tanh"):
"""Градієнт крос-ентропії по всіх параметрах. Один прохід назад по графу."""
кеш = прямий_прохід(ваги, X, активація)
кількість_обʼєктів = len(y)
# δ останнього шару: те саме скорочення, яке ми щойно перевірили чисельно.
# Ділимо на n одразу, бо втрата — це середнє, а не сума.
дельта2 = (кеш["A2"] - y[:, None]) / кількість_обʼєктів
градієнти = {
"W2": кеш["A1"].T @ дельта2, # «хто винен» × «що було на вході»
"b2": дельта2.sum(axis=0), # зсув має вхід «одиниця», тому просто сума
}
# протягуємо дельту назад крізь ваги другого шару
дельта1 = дельта2 @ ваги["W2"].T
if активація == "tanh":
# множимо на похідну активації: саме тут градієнт може затухнути
дельта1 = дельта1 * (1 - кеш["A1"] ** 2)
градієнти["W1"] = X.T @ дельта1
градієнти["b1"] = дельта1.sum(axis=0)
return градієнти
градієнти = зворотний_прохід(демо_ваги, X_навч, y_навч)
print("норма градієнта по кожному параметру:")
for назва, значення in градієнти.items():
print(f" {назва}: форма {значення.shape}, ‖∇‖ = {np.linalg.norm(значення):.6f}")
норма градієнта по кожному параметру: W2: форма (4, 1), ‖∇‖ = 0.062388 b2: форма (1,), ‖∇‖ = 0.001208 W1: форма (2, 4), ‖∇‖ = 0.032467 b1: форма (4,), ‖∇‖ = 0.033650
А як переконатися, що backprop написаний правильно? Формули легко написати з помилкою: забув транспонування, переплутав знак, помножив не на ту похідну — і мережа все одно навчиться, просто гірше. Виняток не впаде, лосс спадатиме, а знайти таку помилку очима майже неможливо.
Залізний спосіб один: порівняти кожну похідну з чисельною, порахованою через центральну різницю $\big(L(w+h) - L(w-h)\big) / 2h$. Ця перевірка з усіма орієнтирами відносної похибки, а поруч дослід «зламай градієнт навмисно» — у практиці теми 32, розділ 4. Та сама функція перевірена там рядок за рядком; тут ми беремо її як готову й ідемо вчити мережу.
4. Навчання: багато маленьких кроків¶
Градієнт порахований — далі все просто, точнісінько як у лінійній регресії:
$$W := W - \eta \cdot \frac{\partial L}{\partial W}$$
Беремо повний градієнтний спуск (градієнт по всій вибірці одразу) — на 280 точках це швидко, а код лишається прозорим.
def навчити(ваги, X, y, крок_навчання=1.0, епох=4000, активація="tanh"):
"""Повний градієнтний спуск. Повертає навчені ваги та історію втрат."""
ваги = {назва: значення.copy() for назва, значення in ваги.items()} # не псуємо оригінал
історія = []
for епоха in range(епох):
градієнти = зворотний_прохід(ваги, X, y, активація)
for назва in ваги:
ваги[назва] -= крок_навчання * градієнти[назва]
# зберігаємо втрату не щокроку, щоб не гальмувати навчання зайвими проходами
if епоха % 20 == 0:
історія.append(крос_ентропія(ваги, X, y, активація))
return ваги, np.array(історія)
def точність(ваги, X, y, активація="tanh"):
прогноз = прямий_прохід(ваги, X, активація)["A2"][:, 0]
return float(np.mean((прогноз > 0.5).astype(int) == y))
початкові_ваги = створити_мережу(8, np.random.default_rng(42))
навчені_ваги, історія_втрат = навчити(початкові_ваги, X_навч, y_навч)
print(f"втрата до навчання : {крос_ентропія(початкові_ваги, X_навч, y_навч):.4f}")
print(f"втрата після : {крос_ентропія(навчені_ваги, X_навч, y_навч):.4f}")
print(f"\nточність на навчальних: {точність(навчені_ваги, X_навч, y_навч):.4f}")
print(f"точність на тестових : {точність(навчені_ваги, X_тест, y_тест):.4f}")
втрата до навчання : 0.7169 втрата після : 0.0214 точність на навчальних: 0.9929 точність на тестових : 0.9833
fig, (ax_втрати, ax_межа) = plt.subplots(1, 2, figsize=(13, 5.5))
ax_втрати.plot(np.arange(len(історія_втрат)) * 20, історія_втрат, lw=2, color="crimson")
ax_втрати.set_yscale("log")
ax_втрати.set_xlabel("епоха")
ax_втрати.set_ylabel("крос-ентропія (лог. шкала)")
ax_втрати.set_title("Крива навчання")
ax_втрати.grid(alpha=.25)
# межа рішень: прогноз мережі в кожній точці сітки
осі = np.linspace(-1.6, 1.6, 220)
сітка_x, сітка_y = np.meshgrid(осі, осі)
точки_сітки = np.column_stack([сітка_x.ravel(), сітка_y.ravel()])
ймовірності = прямий_прохід(навчені_ваги, точки_сітки)["A2"][:, 0].reshape(сітка_x.shape)
ax_межа.contourf(сітка_x, сітка_y, ймовірності, levels=20, cmap="RdBu", alpha=.7)
ax_межа.contour(сітка_x, сітка_y, ймовірності, levels=[0.5], colors="black", linewidths=2)
ax_межа.scatter(X_тест[y_тест == 0, 0], X_тест[y_тест == 0, 1], s=26,
color="crimson", edgecolor="white", linewidth=.6)
ax_межа.scatter(X_тест[y_тест == 1, 0], X_тест[y_тест == 1, 1], s=26,
color="teal", edgecolor="white", linewidth=.6)
ax_межа.set_title(f"Межа рішень (тестові точки), test = {точність(навчені_ваги, X_тест, y_тест):.3f}")
ax_межа.set_aspect("equal")
plt.tight_layout(); plt.show()
Чорна лінія — це рівень 0.5, тобто межа рішень. Вісім нейронів по вісім прямих склали замкнене кільце. Кожен нейрон окремо вміє лише одну пряму — усе інше зробила нелінійність між шарами.
5. Доказ: без нелінійності глибина не існує¶
Найважливіше твердження лекції доводиться в три рядки:
$$\hat y = W^{(2)}(W^{(1)}x + b^{(1)}) + b^{(2)} = (W^{(2)}W^{(1)})x + (W^{(2)}b^{(1)} + b^{(2)}) = \tilde W x + \tilde b$$
Добуток двох матриць — знову матриця. Отже, два лінійні шари дають рівно те саме, що один. Перевіримо це двома способами: спершу алгебраїчно, потім емпірично.
# алгебраїчна перевірка: згортаємо два шари в один і порівнюємо виходи
лінійна_мережа = створити_мережу(8, np.random.default_rng(42))
еквівалентна_матриця = лінійна_мережа["W1"] @ лінійна_мережа["W2"]
еквівалентний_зсув = лінійна_мережа["b1"] @ лінійна_мережа["W2"] + лінійна_мережа["b2"]
вихід_двох_шарів = прямий_прохід(лінійна_мережа, X_тест, активація="linear")["A2"]
вихід_одного_шару = сигмоїда(X_тест @ еквівалентна_матриця + еквівалентний_зсув)
параметрів_у_мережі = sum(значення.size for значення in лінійна_мережа.values())
параметрів_еквівалента = еквівалентна_матриця.size + еквівалентний_зсув.size
print(f"параметрів у мережі 2 → 8 → 1 без активації: {параметрів_у_мережі}")
print(f"параметрів в еквівалентному одному шарі : {параметрів_еквівалента}")
print(f"\nмаксимальна різниця виходів: {np.max(np.abs(вихід_двох_шарів - вихід_одного_шару)):.2e}")
assert np.allclose(вихід_двох_шарів, вихід_одного_шару), "згортка шарів не спрацювала!"
print(f"\n✅ зайвих параметрів: {параметрів_у_мережі - параметрів_еквівалента}. "
"Глибини немає — є одна пряма.")
параметрів у мережі 2 → 8 → 1 без активації: 33 параметрів в еквівалентному одному шарі : 3 максимальна різниця виходів: 1.11e-16 ✅ зайвих параметрів: 30. Глибини немає — є одна пряма.
# емпірична перевірка: навчаємо ту саму мережу з лінійною активацією
лінійно_навчена, історія_лінійна = навчити(лінійна_мережа, X_навч, y_навч, активація="linear")
логістична = LogisticRegression().fit(X_навч, y_навч)
print("ТОЧНІСТЬ НА ТЕСТОВИХ ДАНИХ")
print(f" мережа 2 → 8 → 1 з tanh : {точність(навчені_ваги, X_тест, y_тест):.4f}")
print(f" мережа 2 → 8 → 1 без активації: {точність(лінійно_навчена, X_тест, y_тест, 'linear'):.4f}")
print(f" звичайна логістична регресія : {логістична.score(X_тест, y_тест):.4f}")
print("\nДві останні цифри — це те саме число з точністю до випадковості спуску.")
print("Мережа без нелінійності — це логістична регресія у дорогій обгортці.")
ТОЧНІСТЬ НА ТЕСТОВИХ ДАНИХ мережа 2 → 8 → 1 з tanh : 0.9833 мережа 2 → 8 → 1 без активації: 0.4500 звичайна логістична регресія : 0.4500 Дві останні цифри — це те саме число з точністю до випадковості спуску. Мережа без нелінійності — це логістична регресія у дорогій обгортці.
# і те саме очима: спробуємо додати нейронів у лінійну мережу
print("ширина точність test (лінійна активація)")
for скільки_нейронів in [1, 2, 4, 8, 16, 32]:
ваги_ширші = створити_мережу(скільки_нейронів, np.random.default_rng(42))
ваги_ширші, _ = навчити(ваги_ширші, X_навч, y_навч, епох=2000, активація="linear")
print(f"{скільки_нейронів:6d} {точність(ваги_ширші, X_тест, y_тест, 'linear'):.4f}")
print("\nШирина не допомагає взагалі: скільки б нейронів ми не додали,")
print("композиція лінійних шарів лишається однією прямою.")
ширина точність test (лінійна активація)
1 0.4500
2 0.4500
4 0.4500
8 0.4500
16 0.4500
32 0.4500 Ширина не допомагає взагалі: скільки б нейронів ми не додали, композиція лінійних шарів лишається однією прямою.
Це одна з найпоширеніших мовчазних помилок новачків. Забув активацію між шарами — код не впаде, лосс буде спадати, модель щось покаже. Просто це буде лінійна модель з мільйоном зайвих параметрів, і ти витратиш години на пошук «чому не працює».
6. Результат залежить від початкових ваг¶
Поверхня втрат нейромережі не опукла: у неї є локальні мінімуми, сідлові точки, плато й експоненційно багато симетрій (переставив два нейрони місцями — інша точка простору параметрів із тією самою втратою).
Практичний наслідок відчувається одразу. Той самий код, ті самі дані, той самий крок — інше зерно ініціалізації, і результат інший.
print("зерно фінальна втрата точність test")
результати_зерен = []
втрати_зерен = []
for зерно in range(8):
ваги_зерна = створити_мережу(4, np.random.default_rng(зерно)) # вузька мережа — розкид помітніший
ваги_зерна, _ = навчити(ваги_зерна, X_навч, y_навч, епох=2000)
втрата = крос_ентропія(ваги_зерна, X_навч, y_навч)
тест = точність(ваги_зерна, X_тест, y_тест)
результати_зерен.append(тест)
втрати_зерен.append(втрата)
print(f"{зерно:5d} {втрата:15.4f} {тест:.4f}")
print(f"\nрозкид точності: від {min(результати_зерен):.4f} до {max(результати_зерен):.4f} "
f"(різниця {max(результати_зерен) - min(результати_зерен):.1%})")
print(f"розкид фінальної втрати: від {min(втрати_зерен):.4f} до {max(втрати_зерен):.4f}")
print("\nОднакові дані, однаковий код, однаковий крок — і різні результати.")
print("Головне тут не розмір розкиду, а сам факт: спуск зупинився у РІЗНИХ точках.")
зерно фінальна втрата точність test
0 0.0478 0.9833
1 0.0480 0.9833
2 0.0516 1.0000
3 0.0487 0.9667
4 0.0553 1.0000
5 0.0558 1.0000
6 0.0486 0.9833
7 0.0563 1.0000 розкид точності: від 0.9667 до 1.0000 (різниця 3.3%) розкид фінальної втрати: від 0.0478 до 0.0563 Однакові дані, однаковий код, однаковий крок — і різні результати. Головне тут не розмір розкиду, а сам факт: спуск зупинився у РІЗНИХ точках.
Розкид тут скромний — кілька відсоткових пунктів, — бо «два кола» задача проста й поверхня втрат до нас поблажлива. На спіралі з інтерактиву 3 лекції той самий ефект дає розкид майже у 17 відсоткових пунктів: від 83.1 % до 100 %. Але сам факт уже видно: різні зерна приводять спуск у різні точки з різною фінальною втратою.
Чому мінімумів багато: симетрії¶
Ось найпростіша причина неопуклості, і її можна показати точно. Переставимо місцями два нейрони прихованого шару разом із їхніми вагами — вийде інша точка простору параметрів. Але мережа рахуватиме рівно те саме.
переставлені = {назва: значення.copy() for назва, значення in навчені_ваги.items()}
# міняємо місцями нейрони 0 і 3: у першому шарі це стовпці, у другому — рядки
переставлені["W1"][:, [0, 3]] = переставлені["W1"][:, [3, 0]]
переставлені["b1"][[0, 3]] = переставлені["b1"][[3, 0]]
переставлені["W2"][[0, 3], :] = переставлені["W2"][[3, 0], :]
вихід_оригіналу = прямий_прохід(навчені_ваги, X_тест)["A2"]
вихід_переставленої = прямий_прохід(переставлені, X_тест)["A2"]
print(f"ваги змінились? {not np.allclose(навчені_ваги['W1'], переставлені['W1'])}")
print(f"максимальна різниця виходів: {np.max(np.abs(вихід_оригіналу - вихід_переставленої)):.2e}")
assert np.allclose(вихід_оригіналу, вихід_переставленої), "перестановка щось зламала!"
print("\n✅ інша точка простору параметрів — та сама функція й та сама втрата")
# скільки таких копій має кожен мінімум: 8! перестановок × 2^8 змін знаку (tanh непарна)
from math import factorial
print(f"\nЛише перестановками 8 нейронів кожен мінімум копіюється {factorial(8):,} разів.")
print("А tanh непарна, тому знак ваг нейрона теж можна перевернути парою — ще ×2⁸.")
print(f"Разом {factorial(8) * 2 ** 8:,} однакових за втратою точок. Мінімум точно не один.")
ваги змінились? True максимальна різниця виходів: 4.44e-16 ✅ інша точка простору параметрів — та сама функція й та сама втрата Лише перестановками 8 нейронів кожен мінімум копіюється 40,320 разів. А tanh непарна, тому знак ваг нейрона теж можна перевернути парою — ще ×2⁸. Разом 10,321,920 однакових за втратою точок. Мінімум точно не один.
7. Порівняння з MLPClassifier¶
Тепер найцікавіше: чи вміє наша мережа на 60 рядків те саме, що бібліотечна? Візьмемо однакову архітектуру — один прихований шар із 8 нейронів і tanh.
Одну відмінність варто назвати одразу: MLPClassifier за замовчуванням
використовує не чистий градієнтний спуск, а розумніші оптимізатори (adam, lbfgs)
і додає L2-регуляризацію (alpha). Тому очікувати збігу чисел не варто —
порівнюємо якість.
бібліотечна = MLPClassifier(hidden_layer_sizes=(8,), activation="tanh",
solver="lbfgs", max_iter=3000, random_state=0)
бібліотечна.fit(X_навч, y_навч)
print(" train test")
print(f"наша мережа на NumPy {точність(навчені_ваги, X_навч, y_навч):.4f} "
f"{точність(навчені_ваги, X_тест, y_тест):.4f}")
print(f"MLPClassifier (8, tanh) {бібліотечна.score(X_навч, y_навч):.4f} "
f"{бібліотечна.score(X_тест, y_тест):.4f}")
print(f"логістична регресія {логістична.score(X_навч, y_навч):.4f} "
f"{логістична.score(X_тест, y_тест):.4f}")
параметрів_у_нас = sum(w.size for w in навчені_ваги.values())
параметрів_у_sklearn = sum(w.size for w in бібліотечна.coefs_) + \
sum(b.size for b in бібліотечна.intercepts_)
print(f"\nпараметрів у нашій мережі : {параметрів_у_нас}")
print(f"параметрів у MLPClassifier: {параметрів_у_sklearn}")
assert параметрів_у_нас == параметрів_у_sklearn, "архітектури різні!"
print(f"\n✅ архітектура та сама: 2 → 8 → 1, параметрів {параметрів_у_нас}")
train test наша мережа на NumPy 0.9929 0.9833 MLPClassifier (8, tanh) 1.0000 0.9750 логістична регресія 0.5071 0.4500 параметрів у нашій мережі : 33 параметрів у MLPClassifier: 33 ✅ архітектура та сама: 2 → 8 → 1, параметрів 33
Різниця в кілька десятих відсотка — і вона не про алгоритм, а про оптимізатор
і регуляризацію. Всередині MLPClassifier те саме, що ми написали руками:
прямий прохід, крос-ентропія, backprop, крок по градієнту.
💻 Завдання¶
🟢 Рівень 1 — разом¶
- Пограйся зі швидкістю навчання: постав
крок_навчаннярівним 0.01, 0.1, 1.0, 5.0, 30.0 і побудуй усі криві втрат на одному графіку в логарифмічній шкалі. Де починається «пилка», а де навчання просто не встигає? - Змінити
прихованих_нейронівз 8 на 1, 2, 3, 4. Скільки нейронів мінімально потрібно, щоб замкнути кільце навколо внутрішнього кола?
🟡 Рівень 2 — самостійно¶
- Додай у
прямий_прохідактивацію ReLU (np.maximum(0, z)) і відповідну похідну узворотний_прохід((z > 0)). Порівняй швидкість збіжності tanh і ReLU на одному графіку. Обовʼязково перенеси сюдичисельний_градієнтіз практики теми 32 і прогони перевірку: нова похідна — найчастіше місце помилки. - Заміни
make_circlesнаmake_moons(noise=0.2)і на спіраль. Скільки нейронів треба кожній задачі? Побудуй таблицю «датасет × ширина → точність».
🔴 Рівень 3 — виклик¶
- Додай другий прихований шар: 2 → H → H → 1. Перепиши прямий і зворотний проходи так, щоб кількість шарів була параметром (список матриць замість чотирьох змінних). Перевір градієнт чисельно на трьох і чотирьох шарах — код перевірки бери з практики теми 32.
- Відтвори затухання градієнта: побудуй мережу з 12 шарів по 6 нейронів, зроби один прямий і один зворотний прохід і намалюй норму градієнта на кожному шарі в логарифмічній шкалі — окремо для sigmoid, tanh і ReLU. Порівняй виміряний множник на шар із оцінкою $0.25$ і подивись, наскільки вони розходяться; у темі 33 той самий дослід зроблено докладніше.
🧪 Самоперевірка¶
1. Ти написав backprop, мережа вчиться, лосс спадає. Чи це доводить, що градієнт правильний?
відповідь
Ні. Мережа з помилкою в градієнті теж вчиться — просто гірше й повільніше. Помилка не впаде з винятком, лосс спадатиме. Єдиний надійний доказ — чисельна перевірка кожної похідної; вона розібрана в практиці теми 32.2. Мережа 2 → 8 → 1 з tanh дала на тесті 0.95, а з лінійною активацією — 0.58. Чому додавання нейронів лінійній мережі не допомогло?
відповідь
Бо композиція лінійних шарів згортається в один лінійний шар: скільки нейронів не додавай, множина функцій, які мережа здатна виразити, лишається тією самою. Ми показали це двічі — алгебраїчно (assert на збіг виходів) і емпірично (таблиця «ширина → точність», де число не рухається).3. Мережа 2 → 100 → 100 → 1 без активацій. Скільки різних функцій вона може виразити?
відповідь
Рівно стільки ж, скільки одна пряма — композиція лінійних відображень лінійна. Гірше того: множина досяжних матриць може бути вужчою, ніж у чесного одного шару, бо ранг добутку не перевищує рангу найвужчого співмножника.4. Той самий код і дані дали 0.98 і 0.86 при різних random_state. Що робити?