Практика · Зворотне поширення помилки¶

📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.md

Лекція розібрала backprop на числах і склала таблицю ручного розрахунку. Тут ми зберемо ту саму мережу 2 → 2 → 1 на чистому NumPy і доведемо, що всі числа збігаються — до останнього знака, а не «на око».

Що зробимо:

  1. відтворимо ручний приклад із лекції: прямий прохід, втрата, усі девʼять градієнтів;
  2. перевіримо градієнти чисельно — центральною різницею, зі збігом до 1e-9;
  3. зробимо один крок спуску й побачимо, як падає втрата;
  4. зберемо дошку оголошень із теми 08, витягнемо дві ознаки й навчимо на них мережу;
  5. порівняємо результат із MLPClassifier зі scikit-learn;
  6. наприкінці навмисно зламаємо одну формулу backprop і подивимось, що ловить чисельна перевірка, а чого не видно ні з коду, ні з кривої втрат.

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

In [1]:
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 — оголошення справді шахрайське.
In [2]:
# один обʼєкт як таблиця з одного рядка: далі той самий код працюватиме й на 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₂).

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

In [3]:
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

Звіримо з таблицею лекції. Це не формальність: якщо тут розійдеться хоч один знак, усе подальше буде брехнею.

In [4]:
# числа з таблиці 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).

In [5]:
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]

Ті самі девʼять чисел стоять у нижній половині таблиці лекції. Перевіряємо кожне.

In [6]:
# останні девʼять рядків таблиці 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, на крихітній мережі — обовʼязково.

In [7]:
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
In [8]:
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 стільки знаків не тримає.

In [9]:
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 лише постачає числа, крок робить оптимізатор.

In [10]:
крок_навчання = 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 · Будуємо дані¶

In [11]:
кількість_оголошень = 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]
In [12]:
# «типова» ціна — скільки телефон коштує за паспортом: нова ціна мінус знос,
# помножена на коефіцієнти стану й памʼяті
коефіцієнт_стану = 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.]
In [13]:
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 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
Out[13]:
модель вік_акаунта ціна шахрайське
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.

In [14]:
медіана_по_моделі = дошка.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
In [15]:
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
In [16]:
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("Жодна пряма їх не відділить: потрібна саме нелінійна межа.")
No description has been provided for this image
Шахрайські оголошення сидять двома смугами — знизу (занижена ціна) і зверху (завищена).
Жодна пряма їх не відділить: потрібна саме нелінійна межа.

8 · Навчання¶

Той самий зворотний_прохід, який ми щойно перевірили чисельно, тепер працює на 840 оголошеннях замість одного. Жодного рядка міняти не довелось: формули однакові для будь-якої кількості обʼєктів.

In [17]:
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
In [18]:
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("Два прихованих нейрони вирізали смугу «звичайна ціна» — усе поза нею модель вважає підозрілим.")
No description has been provided for this image
Два прихованих нейрони вирізали смугу «звичайна ціна» — усе поза нею модель вважає підозрілим.

9 · Порівняння з бібліотекою¶

Найголовніша перевірка курсу: всередині scikit-learn немає магії. Беремо MLPClassifier тієї ж форми, з тією ж активацією — і дивимось, чи вийде те саме. Числа не збіжаться до знака (там інший оптимізатор і своя ініціалізація), але точність має бути та сама.

In [19]:
бібліотечна = 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 так, як його ламають найчастіше: забудемо помножити на похідну активації. Один зниклий множник.

In [20]:
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

Око бачить лише те, що якісь числа інші. Але які з них правильні? Без чисельної перевірки відповіді немає. З нею — є, і однозначна.

In [21]:
вектор_зламаний = в_один_вектор(зламані)

похибка_правильного = (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 · А тепер найнеприємніше¶

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

In [22]:
мережа_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

Зламана мережа ледве обігнала базову відповідь «усі чесні». Її крива втрат після
сотої епохи повзе вгору — але сама по собі така крива схожа на завелику швидкість навчання.
In [23]:
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("Мораль: криву втрат не можна використовувати як доказ правильності градієнта.")
No description has been provided for this image
Мораль: криву втрат не можна використовувати як доказ правильності градієнта.

Завдання¶

🟢 Рівень 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, мережа навчається, а ти можеш показати числами, у скільки разів норма градієнта першого шару менша за норму градієнта останнього.