Практика 31 · Нейронні мережі¶

📖 Теорія: відкрий lecture.html у цій же теці (блок 7, тема 31). 📝 Домашнє: homework.md · 🧪 Тест: quiz.html

Лекція розібрала мережу на формули. Тут ми зберемо її з нуля на чистому NumPy: прямий прохід, крос-ентропія, зворотне поширення — і навчання, яке справді зменшує втрату. А потім перевіримо два твердження лекції, у які легше не повірити, ніж повірити: що без активації глибина не існує і що результат залежить від зерна.

Що зробимо:

  1. Реалізуємо прямий прохід мережі 2 → H → 1 руками
  2. Порахуємо крос-ентропію й перевіримо чисельно, що $\partial L/\partial z = \hat y - y$
  3. Напишемо зворотне поширення — три формули, три рядки коду
  4. Навчимо мережу на задачі «два кола» й намалюємо межу рішень
  5. ⭐ Доведемо assert-ом, що без нелінійності мережа лишається лінійною
  6. Побачимо на числах, що результат залежить від початкових ваг
  7. Порівняємо свою мережу з MLPClassifier

Де решта backprop. Виведення трьох формул крок за кроком, чисельна перевірка кожної похідної й дослід «зламай градієнт навмисно» живуть у практиці теми 32 — тут ми беремо готову функцію й одразу вчимо мережу.

Про позначення. У лекції формули записані для одного обʼєкта-стовпчика: $z = Wa + b$, матриця $W$ має розмір «нейронів × входів». У коді зручніше тримати обʼєкти рядками таблиці, тому всі матриці транспоновані: $Z = AW + b$. Це та сама математика, записана навпаки.

In [1]:
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
In [2]:
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()
No description has been provided for this image

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)})$$

Ваги ініціалізуємо не нулями (тоді всі нейрони шару лишились би однаковими назавжди) і не абияк, а за схемою, яка тримає дисперсію сигналу сталою з глибиною.

In [3]:
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)$ з похідної крос-ентропії скорочується з тим самим множником із похідної сигмоїди. Перевіримо це чисельно на одному числі — не повіримо на слово.

In [4]:
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, яке вже порахував прямий прохід.

In [5]:
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 точках це швидко, а код лишається прозорим.

In [6]:
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
In [7]:
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()
No description has been provided for this image

Чорна лінія — це рівень 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$$

Добуток двох матриць — знову матриця. Отже, два лінійні шари дають рівно те саме, що один. Перевіримо це двома способами: спершу алгебраїчно, потім емпірично.

In [8]:
# алгебраїчна перевірка: згортаємо два шари в один і порівнюємо виходи
лінійна_мережа = створити_мережу(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. Глибини немає — є одна пряма.
In [9]:
# емпірична перевірка: навчаємо ту саму мережу з лінійною активацією
лінійно_навчена, історія_лінійна = навчити(лінійна_мережа, 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

Дві останні цифри — це те саме число з точністю до випадковості спуску.
Мережа без нелінійності — це логістична регресія у дорогій обгортці.
In [10]:
# і те саме очима: спробуємо додати нейронів у лінійну мережу
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. Результат залежить від початкових ваг¶

Поверхня втрат нейромережі не опукла: у неї є локальні мінімуми, сідлові точки, плато й експоненційно багато симетрій (переставив два нейрони місцями — інша точка простору параметрів із тією самою втратою).

Практичний наслідок відчувається одразу. Той самий код, ті самі дані, той самий крок — інше зерно ініціалізації, і результат інший.

In [11]:
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 %. Але сам факт уже видно: різні зерна приводять спуск у різні точки з різною фінальною втратою.

Чому мінімумів багато: симетрії¶

Ось найпростіша причина неопуклості, і її можна показати точно. Переставимо місцями два нейрони прихованого шару разом із їхніми вагами — вийде інша точка простору параметрів. Але мережа рахуватиме рівно те саме.

In [12]:
переставлені = {назва: значення.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). Тому очікувати збігу чисел не варто — порівнюємо якість.

In [13]:
бібліотечна = 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 — разом¶

  1. Пограйся зі швидкістю навчання: постав крок_навчання рівним 0.01, 0.1, 1.0, 5.0, 30.0 і побудуй усі криві втрат на одному графіку в логарифмічній шкалі. Де починається «пилка», а де навчання просто не встигає?
  2. Змінити прихованих_нейронів з 8 на 1, 2, 3, 4. Скільки нейронів мінімально потрібно, щоб замкнути кільце навколо внутрішнього кола?

🟡 Рівень 2 — самостійно¶

  1. Додай у прямий_прохід активацію ReLU (np.maximum(0, z)) і відповідну похідну у зворотний_прохід ((z > 0)). Порівняй швидкість збіжності tanh і ReLU на одному графіку. Обовʼязково перенеси сюди чисельний_градієнт із практики теми 32 і прогони перевірку: нова похідна — найчастіше місце помилки.
  2. Заміни make_circles на make_moons(noise=0.2) і на спіраль. Скільки нейронів треба кожній задачі? Побудуй таблицю «датасет × ширина → точність».

🔴 Рівень 3 — виклик¶

  1. Додай другий прихований шар: 2 → H → H → 1. Перепиши прямий і зворотний проходи так, щоб кількість шарів була параметром (список матриць замість чотирьох змінних). Перевір градієнт чисельно на трьох і чотирьох шарах — код перевірки бери з практики теми 32.
  2. Відтвори затухання градієнта: побудуй мережу з 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. Що робити?

відповідь Це нормальна властивість неопуклої задачі, а не баг. На практиці борються трьома способами: розумною ініціалізацією (He для ReLU, Glorot для tanh), адаптивними оптимізаторами (Adam) і міні-батчами — шум від вибірковості допомагає вискочити з дрібних ям і сідлових точок.